--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 7023a7a232fdf4b6955d784ec1b0612f1ff74ff6
Parents : 220a1bc
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-10T00:57:43-05:00
feat(mcx-bugs): local delivery, tabs, toasts, search, report view/export/delete
Changes
17 files changed, 3033 insertions(+), 201 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 4cca7485..82a4e718 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -25,6 +25,7 @@ import secrets
import shutil
import signal
import socket
+import sqlite3
import ssl
import sys
import tempfile
@@ -122,6 +123,13 @@ from meshchatx.src.backend.map_manager import (
is_mbtiles_filename,
is_path_within_dir,
)
+from meshchatx.src.backend.map_overlay_export import OverlayExportError
+from meshchatx.src.backend.map_overlay_manager import (
+ CONFIG_CLAMPS,
+ clamp_overlay_config_value,
+)
+from meshchatx.src.backend.map_overlay_sources import OverlaySourceParseError
+from meshchatx.src.backend.map_geo_validator import GeoValidationError
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
from meshchatx.src.backend.meshchat_utils import (
convert_db_favourite_to_dict,
@@ -166,10 +174,15 @@ from meshchatx.src.backend.csrf import (
)
from meshchatx.src.backend.ip_allowlist import client_ip_allowed
from meshchatx.src.backend.reticulum_config_guard import (
+ ensure_safe_reticulum_runtime_flags,
repair_unparseable_reticulum_config,
reticulum_config_has_required_sections,
)
from meshchatx.src.backend import i2p_support
+from meshchatx.src.backend.rns_startup_recovery import (
+ create_reticulum_with_recovery,
+ install_rns_panic_containment,
+)
from meshchatx.src.backend.websocket_config_guard import (
sanitize_websocket_config_update,
websocket_type_requires_auth,
@@ -308,9 +321,10 @@ def _create_reticulum_instance(config_dir: str, loglevel: int | None = None):
``signal.signal`` on the main thread, so deferred network setup must skip that
registration when running in a background worker and install handlers later.
- If the first init fails and the config still has an enabled I2P interface,
- disable I2P and retry once so Android/desktop can recover without wiping
- app data or the whole ``.reticulum`` tree.
+ On failure, progressively disables risky interfaces (I2P, unsupported RNode,
+ AutoInterface, etc.) and retries so Android/desktop can recover without
+ wiping app data or the whole ``.reticulum`` tree. ``RNS.panic`` is contained
+ so it cannot ``os._exit`` the MeshChatX process.
"""
kwargs = {}
if loglevel is not None:
@@ -334,18 +348,10 @@ def _create_reticulum_instance(config_dir: str, loglevel: int | None = None):
finally:
signal.signal = real_signal
- try:
- return _construct()
- except Exception as first_exc:
- config_path = os.path.join(config_dir, "config")
- if not i2p_support.disable_all_i2p_in_config(config_path):
- raise
- print(
- "Reticulum init failed with I2P enabled; disabled I2P interfaces "
- f"and retrying. Original error: {first_exc}",
- flush=True,
- )
- return _construct()
+ return create_reticulum_with_recovery(
+ config_dir,
+ construct=_construct,
+ )
def _install_reticulum_signal_handlers() -> bool:
@@ -469,6 +475,9 @@ class ReticulumMeshChat:
self._startup_stage = "ready" if not defer_network_setup else "http"
self._startup_error: str | None = None
self._network_ready = not defer_network_setup
+ self._network_degraded = False
+ self._ui_ready = not defer_network_setup
+ self._rns_recovery_actions: list[str] = []
# track announce timestamps for rate calculation
self.announce_timestamps = []
@@ -488,6 +497,9 @@ class ReticulumMeshChat:
self.identity_manager = IdentityManager(self.storage_dir, identity_file_path)
self.page_node_manager = PageNodeManager(self.storage_dir)
self.plugin_manager = PluginManager(self.storage_dir, app=self)
+ from meshchatx.src.backend.bug_report_manager import BugReportManager
+
+ self.bug_report_manager = BugReportManager(self)
self.sideband_plugin_loader = SidebandPluginLoader(self)
self._sideband_telemetry_thread = None
self._sideband_telemetry_running = False
@@ -589,6 +601,17 @@ class ReticulumMeshChat:
if self.current_context:
self.current_context.map_manager = value
+ @property
+ def map_overlay_manager(self):
+ return (
+ self.current_context.map_overlay_manager if self.current_context else None
+ )
+
+ @map_overlay_manager.setter
+ def map_overlay_manager(self, value):
+ if self.current_context:
+ self.current_context.map_overlay_manager = value
+
@property
def docs_manager(self):
return self.current_context.docs_manager if self.current_context else None
@@ -1324,6 +1347,7 @@ class ReticulumMeshChat:
guard_rnode_interfaces_on_desktop(config_path)
guard_invalid_rnode_txpower_in_config(config_path)
i2p_support.guard_i2p_interfaces_in_config(config_path)
+ ensure_safe_reticulum_runtime_flags(config_path)
def _set_startup_stage(self, stage: str, error: str | None = None) -> None:
self._startup_stage = stage
@@ -1333,11 +1357,23 @@ class ReticulumMeshChat:
def _mark_network_ready(self) -> None:
self._network_ready = True
+ self._network_degraded = False
+ self._ui_ready = True
self._startup_stage = "ready"
self._startup_error = None
+ self._rns_recovery_actions = []
self._network_ready_event.set()
self._schedule_reticulum_signal_handlers()
+ def _mark_network_degraded(self, error: str) -> None:
+ """Keep HTTP/UI alive when the mesh stack cannot start."""
+ self._network_ready = False
+ self._network_degraded = True
+ self._ui_ready = True
+ self._startup_stage = "failed"
+ self._startup_error = error
+ print(f"Network degraded: {error}", flush=True)
+
def _schedule_reticulum_signal_handlers(self) -> None:
"""Install RNS signal handlers on the main asyncio loop when possible."""
if threading.current_thread() is threading.main_thread():
@@ -1380,6 +1416,8 @@ class ReticulumMeshChat:
"status": "failed",
"stage": "failed",
"network_ready": False,
+ "network_degraded": True,
+ "ui_ready": True,
"listen_host": self.listen_host,
"listen_port": self.listen_port,
"https_enabled": self.use_https,
@@ -1389,6 +1427,8 @@ class ReticulumMeshChat:
}
if self._startup_error:
payload["error"] = self._startup_error
+ if self._rns_recovery_actions:
+ payload["recovery_actions"] = list(self._rns_recovery_actions)
return payload
ready = bool(self._network_ready) and bool(
self.current_context and self.current_context.running,
@@ -1398,6 +1438,8 @@ class ReticulumMeshChat:
"status": "ok" if ready else "starting",
"stage": stage,
"network_ready": ready,
+ "network_degraded": False,
+ "ui_ready": True if ready else bool(self._ui_ready),
"listen_host": self.listen_host,
"listen_port": self.listen_port,
"https_enabled": self.use_https,
@@ -1471,7 +1513,7 @@ class ReticulumMeshChat:
pass
except Exception as exc:
traceback.print_exc()
- self._set_startup_stage("failed", str(exc))
+ self._mark_network_degraded(str(exc))
if self.websocket_clients:
try:
AsyncUtils.run_async(
@@ -1481,6 +1523,8 @@ class ReticulumMeshChat:
"status": "failed",
"stage": "failed",
"network_ready": False,
+ "network_degraded": True,
+ "ui_ready": True,
"error": str(exc),
},
),
@@ -2398,12 +2442,18 @@ class ReticulumMeshChat:
in_progress=False,
)
- # Try to recover if possible
+ # Try to recover if possible without wiping storage.
if not hasattr(self, "reticulum") and identity_to_restore is not None:
try:
self.setup_identity(identity_to_restore)
- except Exception:
- pass
+ self._mark_network_ready()
+ return False
+ except Exception as recover_exc:
+ self._mark_network_degraded(
+ f"RNS reload failed and recovery failed: {recover_exc}",
+ )
+ else:
+ self._mark_network_degraded(f"RNS reload failed: {e}")
return False
@@ -3112,10 +3162,11 @@ class ReticulumMeshChat:
and elapsed > 45.0
)
if propagation_sync_is_terminal(state):
- if state not in {router.PR_IDLE, router.PR_COMPLETE}:
+ if state != router.PR_IDLE:
self.stop_propagation_node_sync(context=ctx)
with contextlib.suppress(Exception):
router.propagation_transfer_state = router.PR_IDLE
+ router.propagation_transfer_progress = 0.0
ctx.config.lxmf_preferred_propagation_node_last_synced_at.set(
int(time.time())
)
@@ -4451,6 +4502,39 @@ class ReticulumMeshChat:
def exit_app(self, code=0):
sys.exit(code)
+ def _require_identity_context_ready(self):
+ """Return an HTTP 503 response when identity/DB is not ready yet."""
+ if not self.current_context or not getattr(
+ self.current_context, "running", False
+ ):
+ return web.json_response(
+ {
+ "message": "Identity context is still starting. Retry shortly.",
+ "stage": self._startup_stage,
+ "network_ready": bool(self._network_ready),
+ },
+ status=503,
+ )
+ if self.database is None or self.message_handler is None:
+ return web.json_response(
+ {
+ "message": "Database is still starting. Retry shortly.",
+ "stage": self._startup_stage,
+ "network_ready": bool(self._network_ready),
+ },
+ status=503,
+ )
+ if self.local_lxmf_destination is None:
+ return web.json_response(
+ {
+ "message": "Local LXMF destination is still starting. Retry shortly.",
+ "stage": self._startup_stage,
+ "network_ready": bool(self._network_ready),
+ },
+ status=503,
+ )
+ return None
+
def _require_outbound_http(self, feature: str) -> None:
if self.config:
ensure_outbound_http_allowed(self.config, feature=feature)
@@ -5128,6 +5212,106 @@ class ReticulumMeshChat:
async def status(request):
return web.json_response(self._startup_status_payload())
+ @routes.post("/api/v1/reticulum/recover")
+ async def reticulum_recover(request):
+ """Disable risky interfaces and retry network setup without wiping data."""
+ if (
+ self._network_ready
+ and self.current_context
+ and self.current_context.running
+ ):
+ return web.json_response(
+ {
+ "message": "Network stack is already running",
+ "status": self._startup_status_payload(),
+ },
+ )
+
+ identity = self._pending_identity or self.identity
+ if identity is None:
+ return web.json_response(
+ {"message": "No identity available for recovery"},
+ status=400,
+ )
+
+ config_path = self._reticulum_config_file_path()
+ actions: list[str] = []
+ try:
+ data = await request.json()
+ except Exception:
+ data = {}
+ if not isinstance(data, dict):
+ data = {}
+
+ disable_all = bool(data.get("disable_all_interfaces"))
+ named = data.get("disable_interfaces")
+ if isinstance(named, list) and named:
+ from meshchatx.src.backend.rns_startup_recovery import (
+ disable_named_interfaces_in_config,
+ )
+
+ disabled = disable_named_interfaces_in_config(
+ config_path,
+ [str(n) for n in named],
+ )
+ actions.extend(disabled)
+ elif disable_all:
+ from meshchatx.src.backend.rns_startup_recovery import (
+ disable_named_interfaces_in_config,
+ list_enabled_interface_names,
+ )
+
+ names = list_enabled_interface_names(config_path)
+ disabled = disable_named_interfaces_in_config(config_path, names)
+ actions.extend(disabled)
+ else:
+ from meshchatx.src.backend.rns_startup_recovery import (
+ apply_startup_recovery_step,
+ )
+
+ for attempt in range(4):
+ disabled = apply_startup_recovery_step(
+ config_path,
+ self._startup_error or "manual recover",
+ attempt=attempt,
+ )
+ actions.extend(disabled)
+ if disabled:
+ break
+
+ self._rns_recovery_actions = actions
+ self._startup_error = None
+ self._startup_stage = "starting"
+ self._network_degraded = False
+ self._ui_ready = True
+ self._network_ready = False
+ if hasattr(self, "reticulum"):
+ with contextlib.suppress(Exception):
+ delattr(self, "reticulum")
+
+ try:
+ self.setup_identity(identity)
+ self._mark_network_ready()
+ return web.json_response(
+ {
+ "message": "Network stack recovered",
+ "disabled_interfaces": actions,
+ "status": self._startup_status_payload(),
+ },
+ )
+ except Exception as exc:
+ traceback.print_exc()
+ self._mark_network_degraded(str(exc))
+ return web.json_response(
+ {
+ "message": "Recovery attempt failed",
+ "error": str(exc),
+ "disabled_interfaces": actions,
+ "status": self._startup_status_payload(),
+ },
+ status=500,
+ )
+
@routes.get("/api/v1/self-test")
async def self_test(request):
results = self.run_self_test()
@@ -7918,7 +8102,7 @@ class ReticulumMeshChat:
body=data,
headers={
"Content-Type": "application/octet-stream",
- "Content-Disposition": 'attachment; filename="identity"',
+ "Content-Disposition": 'attachment; filename="identity.bin"',
},
)
except Exception as e:
@@ -7952,29 +8136,32 @@ class ReticulumMeshChat:
# multipart file upload
if "multipart/form-data" in content_type:
reader = await request.multipart()
+ identity_bytes = None
+ display_name = None
field = await reader.next()
- if field is None or field.name != "file":
+ while field is not None:
+ if field.name == "file":
+ with tempfile.NamedTemporaryFile(delete=False) as tmp:
+ while True:
+ chunk = await field.read_chunk()
+ if not chunk:
+ break
+ tmp.write(chunk)
+ temp_path = tmp.name
+ try:
+ with open(temp_path, "rb") as f:
+ identity_bytes = f.read()
+ finally:
+ with contextlib.suppress(OSError):
+ os.remove(temp_path)
+ elif field.name == "display_name":
+ display_name = (await field.text()).strip() or None
+ field = await reader.next()
+ if identity_bytes is None:
return web.json_response(
{"message": "Identity file is required"},
status=400,
)
- with tempfile.NamedTemporaryFile(delete=False) as tmp:
- while True:
- chunk = await field.read_chunk()
- if not chunk:
- break
- tmp.write(chunk)
- temp_path = tmp.name
- with open(temp_path, "rb") as f:
- identity_bytes = f.read()
- os.remove(temp_path)
- display_name = None
- next_field = await reader.next()
- while next_field is not None:
- if next_field.name == "display_name":
- display_name = (await next_field.text()).strip()
- break
- next_field = await reader.next()
result = self.restore_identity_from_bytes(
identity_bytes,
display_name=display_name,
@@ -7998,6 +8185,13 @@ class ReticulumMeshChat:
"identity": result,
},
)
+ except ValueError as e:
+ return web.json_response(
+ {
+ "message": str(e),
+ },
+ status=400,
+ )
except Exception as e:
return web.json_response(
{
@@ -11361,6 +11555,18 @@ class ReticulumMeshChat:
@routes.get("/api/v1/lxmf/propagation-node/status")
async def propagation_node_status(request):
+ router = self.message_router
+ if router is not None:
+ try:
+ state = router.propagation_transfer_state
+ # COMPLETE is terminal; expose idle so the UI does not keep
+ # looking "busy" after a finished auto/manual sync.
+ if state == router.PR_COMPLETE:
+ router.propagation_transfer_state = router.PR_IDLE
+ with contextlib.suppress(Exception):
+ router.propagation_transfer_progress = 0.0
+ except Exception:
+ pass
sync_metrics = self._collect_propagation_sync_metrics()
return web.json_response(
{
@@ -11852,30 +12058,57 @@ class ReticulumMeshChat:
# get path params
destination_hash_str = request.match_info.get("destination_hash", "")
- # convert destination hash to bytes
- destination_hash = bytes.fromhex(destination_hash_str)
+ try:
+ destination_hash = bytes.fromhex(destination_hash_str)
+ except Exception:
+ return web.json_response(
+ {"message": "Ping failed. Invalid destination hash."},
+ status=400,
+ )
- # determine how long until we should time out
- timeout_seconds = int(request.query.get("timeout", 15))
- timeout_after_seconds = time.time() + timeout_seconds
+ try:
+ timeout_seconds = int(request.query.get("timeout", 15))
+ except (TypeError, ValueError):
+ return web.json_response(
+ {"message": "Ping failed. Timeout must be an integer."},
+ status=400,
+ )
+ if timeout_seconds < 1:
+ return web.json_response(
+ {"message": "Ping failed. Timeout must be at least 1 second."},
+ status=400,
+ )
+
+ # Split the budget so path discovery cannot consume the whole timeout.
+ path_budget_seconds = max(1, timeout_seconds // 2)
+ delivery_budget_seconds = max(1, timeout_seconds - path_budget_seconds)
+ path_deadline = time.time() + path_budget_seconds
# request path if we don't have it
if not RNS.Transport.has_path(destination_hash):
RNS.Transport.request_path(destination_hash)
- # wait until we have a path, or give up after the configured timeout
+ # wait until we have a path, or give up after the path budget
while (
not RNS.Transport.has_path(destination_hash)
- and time.time() < timeout_after_seconds
+ and time.time() < path_deadline
):
await asyncio.sleep(0.1)
+ if not RNS.Transport.has_path(destination_hash):
+ return web.json_response(
+ {
+ "message": "Ping failed. Could not find path to destination.",
+ },
+ status=503,
+ )
+
# find destination identity (pass string hash, not bytes)
destination_identity = self.recall_identity(destination_hash_str)
if destination_identity is None:
return web.json_response(
{
- "message": "Ping failed. Could not find path to destination.",
+ "message": "Ping failed. Could not recall destination identity.",
},
status=503,
)
@@ -11893,10 +12126,11 @@ class ReticulumMeshChat:
packet = RNS.Packet(request_destination, b"")
receipt = packet.send()
- # wait until delivered, or give up after time out
+ delivery_deadline = time.time() + delivery_budget_seconds
+ # wait until delivered, or give up after the delivery budget
while (
receipt.status != RNS.PacketReceipt.DELIVERED
- and time.time() < timeout_after_seconds
+ and time.time() < delivery_deadline
):
await asyncio.sleep(0.1)
@@ -12280,6 +12514,22 @@ class ReticulumMeshChat:
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
+ @routes.post("/api/v1/rncp/cancel")
+ async def rncp_cancel(request):
+ data = {}
+ with contextlib.suppress(Exception):
+ data = await request.json()
+ transfer_id = None
+ if isinstance(data, dict):
+ raw = data.get("transfer_id")
+ if isinstance(raw, str) and raw.strip():
+ transfer_id = raw.strip()
+ try:
+ result = self.rncp_handler.cancel_transfer(transfer_id)
+ return web.json_response(result)
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+
# --- Plugin API ---
@routes.get("/api/v1/plugins")
@@ -12558,7 +12808,14 @@ class ReticulumMeshChat:
return web.json_response({"message": str(e)}, status=400)
except ValueError as e:
return web.json_response({"message": str(e)}, status=400)
- return web.FileResponse(path)
+ return web.FileResponse(
+ path,
+ headers={
+ "Cache-Control": "no-cache, no-store, must-revalidate",
+ "Pragma": "no-cache",
+ "Expires": "0",
+ },
+ )
# --- Page Node API ---
@@ -12792,18 +13049,24 @@ class ReticulumMeshChat:
@routes.get("/api/v1/rnpath/table")
async def rnpath_table(request):
- max_hops = request.query.get("max_hops")
- if max_hops:
- max_hops = int(max_hops)
+ def _optional_int(raw, field_name):
+ if raw in (None, ""):
+ return None
+ try:
+ return int(raw)
+ except (TypeError, ValueError):
+ raise ValueError(f"Invalid {field_name}") from None
+
+ try:
+ max_hops = _optional_int(request.query.get("max_hops"), "max_hops")
+ hops = _optional_int(request.query.get("hops"), "hops")
+ page = int(request.query.get("page", 1))
+ limit = int(request.query.get("limit", 50))
+ except ValueError as e:
+ return web.json_response({"message": str(e)}, status=400)
search = request.query.get("search")
interface = request.query.get("interface")
- hops = request.query.get("hops")
- if hops:
- hops = int(hops)
-
- page = int(request.query.get("page", 1))
- limit = int(request.query.get("limit", 50))
try:
result = self.rnpath_handler.get_path_table(
@@ -13572,12 +13835,18 @@ class ReticulumMeshChat:
},
)
- except Exception:
+ except Exception as e:
+ detail = str(e).strip() or "Sending failed"
+ status = 503
+ if isinstance(e, (ValueError, LookupError)):
+ status = 400
+ elif isinstance(e, TimeoutError):
+ status = 503
return web.json_response(
{
- "message": "Sending failed",
+ "message": detail,
},
- status=503,
+ status=status,
)
@routes.post("/api/v1/lxmf-messages/reactions")
@@ -13612,11 +13881,17 @@ class ReticulumMeshChat:
},
)
except Exception as e:
+ detail = str(e).strip() or "Reaction failed"
+ status = 503
+ if isinstance(e, (ValueError, LookupError)):
+ status = 400
+ elif isinstance(e, TimeoutError):
+ status = 503
return web.json_response(
{
- "message": str(e),
+ "message": detail,
},
- status=503,
+ status=status,
)
# cancel sending lxmf message
@@ -13895,6 +14170,10 @@ class ReticulumMeshChat:
# get lxmf conversations
@routes.get("/api/v1/lxmf/conversations")
async def lxmf_conversations_get(request):
+ not_ready = self._require_identity_context_ready()
+ if not_ready is not None:
+ return not_ready
+
# get query params
search_query = request.query.get("search", request.query.get("q", None))
filter_unread = parse_bool_query_param(
@@ -13935,104 +14214,137 @@ class ReticulumMeshChat:
except ValueError:
offset = 0
- local_hash = self.local_lxmf_destination.hexhash
+ try:
+ local_hash = self.local_lxmf_destination.hexhash
- db_conversations = await asyncio.to_thread(
- self.message_handler.get_conversations,
- local_hash,
- search=search_query,
- filter_unread=filter_unread,
- filter_failed=filter_failed,
- filter_has_attachments=filter_has_attachments,
- folder_id=folder_id,
- limit=limit,
- offset=offset,
- )
+ db_conversations = await asyncio.to_thread(
+ self.message_handler.get_conversations,
+ local_hash,
+ search=search_query,
+ filter_unread=filter_unread,
+ filter_failed=filter_failed,
+ filter_has_attachments=filter_has_attachments,
+ folder_id=folder_id,
+ limit=limit,
+ offset=offset,
+ )
- conversations = []
- for row in db_conversations:
- other_user_hash = row["peer_hash"]
+ conversations = []
+ for row in db_conversations:
+ if not isinstance(row, dict):
+ row = dict(row)
+ other_user_hash = row["peer_hash"]
- display_name = None
- if row["peer_app_data"]:
- display_name = parse_lxmf_display_name(
- app_data_base64=row["peer_app_data"],
- default_value=None,
- )
- if not display_name and row.get("contact_name"):
- display_name = row["contact_name"]
- if not display_name:
- display_name = "Anonymous Peer"
-
- if self._lxmf_sieve_hides_peer(
- other_user_hash,
- message_title=row.get("title"),
- message_content=row.get("content"),
- ):
- continue
+ display_name = None
+ if row.get("peer_app_data"):
+ display_name = parse_lxmf_display_name(
+ app_data_base64=row["peer_app_data"],
+ default_value=None,
+ )
+ if not display_name and row.get("contact_name"):
+ display_name = row["contact_name"]
+ if not display_name:
+ display_name = "Anonymous Peer"
- # user icon
- user_icon = None
- if row["icon_name"]:
- user_icon = {
- "icon_name": row["icon_name"],
- "foreground_colour": row["foreground_colour"],
- "background_colour": row["background_colour"],
- }
+ if self._lxmf_sieve_hides_peer(
+ other_user_hash,
+ message_title=row.get("title"),
+ message_content=row.get("content"),
+ ):
+ continue
- # contact image
- contact_image = row.get("contact_image", None)
+ # user icon
+ user_icon = None
+ if row.get("icon_name"):
+ user_icon = {
+ "icon_name": row["icon_name"],
+ "foreground_colour": row["foreground_colour"],
+ "background_colour": row["background_colour"],
+ }
- is_unread = compute_lxmf_conversation_unread_from_latest_row(row)
+ # contact image
+ contact_image = row.get("contact_image", None)
- # Add extra check for notification viewed state if unread
- if is_unread and filter_unread:
- if self.database.messages.is_notification_viewed(
- other_user_hash,
- row["timestamp"],
- ):
+ try:
+ is_unread = compute_lxmf_conversation_unread_from_latest_row(
+ row
+ )
+ except Exception:
is_unread = False
- if filter_unread:
- continue # Skip this conversation if filtering unread and it's actually viewed
- # add to conversations
- conversations.append(
- {
- "display_name": display_name,
- "custom_display_name": row["custom_display_name"],
- "contact_image": contact_image,
- "destination_hash": other_user_hash,
- "is_unread": is_unread,
- "is_tracking": self.database.telemetry.is_tracking(
+ # Add extra check for notification viewed state if unread
+ if is_unread and filter_unread:
+ if self.database.messages.is_notification_viewed(
other_user_hash,
- ),
- "failed_messages_count": row["failed_count"],
- "has_attachments": message_fields_have_attachments(
- row["fields"],
- ),
- "latest_message_title": row["title"],
- "latest_message_preview": lxmf_sidebar_preview_for_conversation_latest_row(
- row,
- local_hash=local_hash,
- peer_display_name=(
- row.get("custom_display_name")
- or display_name
- or "Anonymous Peer"
+ row["timestamp"],
+ ):
+ is_unread = False
+ if filter_unread:
+ continue # Skip this conversation if filtering unread and it's actually viewed
+
+ has_attachments = bool(
+ row.get("has_attachments") in (1, True, "1")
+ or message_fields_have_attachments(row.get("fields"))
+ )
+
+ # add to conversations
+ conversations.append(
+ {
+ "display_name": display_name,
+ "custom_display_name": row["custom_display_name"],
+ "contact_image": contact_image,
+ "destination_hash": other_user_hash,
+ "is_unread": is_unread,
+ "is_tracking": self.database.telemetry.is_tracking(
+ other_user_hash,
+ ),
+ "failed_messages_count": row["failed_count"],
+ "has_attachments": has_attachments,
+ "latest_message_title": row["title"],
+ "latest_message_preview": lxmf_sidebar_preview_for_conversation_latest_row(
+ row,
+ local_hash=local_hash,
+ peer_display_name=(
+ row.get("custom_display_name")
+ or display_name
+ or "Anonymous Peer"
+ ),
),
+ "latest_message_created_at": row["created_at"],
+ "lxmf_user_icon": user_icon,
+ "is_contact": bool(row.get("is_contact", 0)),
+ "updated_at": row["created_at"],
+ },
+ )
+
+ return web.json_response(
+ {
+ "conversations": conversations,
+ },
+ )
+ except Exception as e:
+ RNS.log(f"Error in lxmf_conversations_get: {e}", RNS.LOG_ERROR)
+ detail = str(e).lower()
+ status = (
+ 503
+ if (
+ isinstance(e, sqlite3.OperationalError)
+ or "unable to open database file" in detail
+ or "database is locked" in detail
+ )
+ else 500
+ )
+ return web.json_response(
+ {
+ "message": (
+ "Database temporarily unavailable. Retry shortly."
+ if status == 503
+ else "Failed to load conversations"
),
- "latest_message_created_at": row["created_at"],
- "lxmf_user_icon": user_icon,
- "is_contact": bool(row.get("is_contact", 0)),
- "updated_at": row["created_at"],
},
+ status=status,
)
- return web.json_response(
- {
- "conversations": conversations,
- },
- )
-
@routes.get("/api/v1/lxmf/folders")
async def lxmf_folders_get(request):
folders = self.database.messages.get_all_folders()
@@ -14313,6 +14625,9 @@ class ReticulumMeshChat:
@routes.get("/api/v1/notifications")
async def notifications_get(request):
+ not_ready = self._require_identity_context_ready()
+ if not_ready is not None:
+ return not_ready
try:
filter_unread = parse_bool_query_param(
request.query.get("unread", "false"),
@@ -14594,9 +14909,25 @@ class ReticulumMeshChat:
)
except Exception as e:
RNS.log(f"Error in notifications_get: {e}", RNS.LOG_ERROR)
+ detail = str(e).lower()
+ status = (
+ 503
+ if (
+ isinstance(e, sqlite3.OperationalError)
+ or "unable to open database file" in detail
+ or "database is locked" in detail
+ )
+ else 500
+ )
return web.json_response(
- {"error": "Internal error"},
- status=500,
+ {
+ "error": (
+ "Database temporarily unavailable. Retry shortly."
+ if status == 503
+ else "Internal error"
+ ),
+ },
+ status=status,
)
# get blocked destinations
@@ -14910,6 +15241,196 @@ class ReticulumMeshChat:
self.database.map_drawings.update_drawing(drawing_id, name, drawing_data)
return web.json_response({"message": "Drawing updated successfully"})
+ @routes.get("/api/v1/map/overlays")
+ async def list_map_overlays(request):
+ if not self.map_overlay_manager:
+ return web.json_response({"error": "unavailable"}, status=503)
+ identity_hash = self.identity.hash.hex()
+ overlays = self.map_overlay_manager.list_overlays(identity_hash)
+ return web.json_response({"overlays": overlays})
+
+ @routes.post("/api/v1/map/overlays")
+ async def create_map_overlays(request):
+ if not self.map_overlay_manager:
+ return web.json_response({"error": "unavailable"}, status=503)
+ try:
+ data = await request.json()
+ except (json.JSONDecodeError, ValueError):
+ return web.json_response({"error": "invalid_json"}, status=400)
+ identity_hash = self.identity.hash.hex()
+ try:
+ result = await self.map_overlay_manager.create_overlays(
+ identity_hash,
+ data,
+ )
+ except OverlaySourceParseError as exc:
+ return web.json_response({"error": exc.code}, status=400)
+ except GeoValidationError as exc:
+ return web.json_response({"error": exc.code}, status=400)
+ return web.json_response(result)
+
+ @routes.post("/api/v1/map/overlays/export")
+ async def export_map_overlays_many(request):
+ if not self.map_overlay_manager:
+ return web.json_response({"error": "unavailable"}, status=503)
+ try:
+ data = await request.json()
+ except (json.JSONDecodeError, ValueError):
+ return web.json_response({"error": "invalid_json"}, status=400)
+ fmt = str(data.get("format") or "geojson").lower()
+ ids = data.get("ids") or []
+ if not isinstance(ids, list):
+ return web.json_response({"error": "missing_ids"}, status=400)
+ try:
+ overlay_ids = [int(i) for i in ids]
+ except (TypeError, ValueError):
+ return web.json_response({"error": "missing_ids"}, status=400)
+ identity_hash = self.identity.hash.hex()
+ try:
+ body, content_type, filename = self.map_overlay_manager.export_many(
+ identity_hash,
+ overlay_ids,
+ fmt,
+ )
+ except OverlayExportError as exc:
+ status = 404 if exc.code == "cache_missing" else 400
+ return web.json_response({"error": exc.code}, status=status)
+ return web.Response(
+ body=body,
+ headers={
+ "Content-Type": content_type,
+ "Content-Disposition": f'attachment; filename="{filename}"',
+ },
+ )
+
+ @routes.get("/api/v1/map/overlays/jobs/{job_id}")
+ async def get_map_overlay_job(request):
+ if not self.map_overlay_manager:
+ return web.json_response({"error": "unavailable"}, status=503)
+ job_id = request.match_info.get("job_id")
+ job = self.map_overlay_manager.get_job(job_id)
+ if not job:
+ return web.json_response({"error": "not_found"}, status=404)
+ return web.json_response(job)
+
+ @routes.post("/api/v1/map/overlays/jobs/{job_id}/cancel")
+ async def cancel_map_overlay_job(request):
+ if not self.map_overlay_manager:
+ return web.json_response({"error": "unavailable"}, status=503)
+ job_id = request.match_info.get("job_id")
+ ok = self.map_overlay_manager.cancel_job(job_id)
+ if not ok:
+ return web.json_response({"error": "not_found"}, status=404)
+ return web.json_response({"cancelled": True})
+
+ @routes.post("/api/v1/map/overlays/{overlay_id}/refresh")
+ async def refresh_map_overlay(request):
+ if not self.map_overlay_manager:
+ return web.json_response({"error": "unavailable"}, status=503)
+ try:
+ overlay_id = int(request.match_info.get("overlay_id"))
+ except (TypeError, ValueError):
+ return web.json_response({"error": "not_found"}, status=404)
+ identity_hash = self.identity.hash.hex()
+ try:
+ result = await self.map_overlay_manager.refresh_overlay(
+ identity_hash,
+ overlay_id,
+ )
+ except OverlaySourceParseError as exc:
+ status = 404 if exc.code == "not_found" else 400
+ return web.json_response({"error": exc.code}, status=status)
+ return web.json_response(result)
+
+ @routes.patch("/api/v1/map/overlays/{overlay_id}")
+ async def patch_map_overlay(request):
+ if not self.map_overlay_manager:
+ return web.json_response({"error": "unavailable"}, status=503)
+ try:
+ overlay_id = int(request.match_info.get("overlay_id"))
+ except (TypeError, ValueError):
+ return web.json_response({"error": "not_found"}, status=404)
+ try:
+ data = await request.json()
+ except (json.JSONDecodeError, ValueError):
+ return web.json_response({"error": "invalid_json"}, status=400)
+ identity_hash = self.identity.hash.hex()
+ try:
+ overlay = self.map_overlay_manager.patch_overlay(
+ identity_hash,
+ overlay_id,
+ data,
+ )
+ except OverlaySourceParseError as exc:
+ status = 404 if exc.code == "not_found" else 400
+ return web.json_response({"error": exc.code}, status=status)
+ return web.json_response({"overlay": overlay})
+
+ @routes.delete("/api/v1/map/overlays/{overlay_id}")
+ async def delete_map_overlay(request):
+ if not self.map_overlay_manager:
+ return web.json_response({"error": "unavailable"}, status=503)
+ try:
+ overlay_id = int(request.match_info.get("overlay_id"))
+ except (TypeError, ValueError):
+ return web.json_response({"error": "not_found"}, status=404)
+ identity_hash = self.identity.hash.hex()
+ ok = self.map_overlay_manager.delete_overlay(identity_hash, overlay_id)
+ if not ok:
+ return web.json_response({"error": "not_found"}, status=404)
+ return web.json_response({"deleted": True})
+
+ @routes.get("/api/v1/map/overlays/{overlay_id}/content")
+ async def get_map_overlay_content(request):
+ if not self.map_overlay_manager:
+ return web.json_response({"error": "unavailable"}, status=503)
+ try:
+ overlay_id = int(request.match_info.get("overlay_id"))
+ except (TypeError, ValueError):
+ return web.json_response({"error": "not_found"}, status=404)
+ identity_hash = self.identity.hash.hex()
+ cached = self.map_overlay_manager.read_cache_bytes(
+ identity_hash, overlay_id
+ )
+ if not cached:
+ return web.json_response({"error": "cache_missing"}, status=404)
+ data, fmt = cached
+ from meshchatx.src.backend.map_overlay_export import CONTENT_TYPES
+
+ return web.Response(
+ body=data,
+ headers={
+ "Content-Type": CONTENT_TYPES.get(fmt, "application/octet-stream")
+ },
+ )
+
+ @routes.get("/api/v1/map/overlays/{overlay_id}/export")
+ async def export_map_overlay(request):
+ if not self.map_overlay_manager:
+ return web.json_response({"error": "unavailable"}, status=503)
+ try:
+ overlay_id = int(request.match_info.get("overlay_id"))
+ except (TypeError, ValueError):
+ return web.json_response({"error": "not_found"}, status=404)
+ fmt = str(request.rel_url.query.get("format") or "geojson").lower()
+ identity_hash = self.identity.hash.hex()
+ try:
+ body, content_type, filename = self.map_overlay_manager.export_overlay(
+ identity_hash,
+ overlay_id,
+ fmt,
+ )
+ except OverlayExportError as exc:
+ status = 404 if exc.code == "cache_missing" else 400
+ return web.json_response({"error": exc.code}, status=status)
+ return web.Response(
+ body=body,
+ headers={
+ "Content-Type": content_type,
+ "Content-Disposition": f'attachment; filename="{filename}"',
+ },
+ )
+
@routes.get("/api/v1/stickers")
async def stickers_list(request):
identity_hash = self.identity.hash.hex()
@@ -16634,6 +17155,16 @@ class ReticulumMeshChat:
if "map_nominatim_api_url" in data:
self.config.map_nominatim_api_url.set(data["map_nominatim_api_url"])
+ for overlay_key in CONFIG_CLAMPS:
+ if overlay_key in data:
+ try:
+ value = int(data[overlay_key])
+ except (TypeError, ValueError):
+ continue
+ getattr(self.config, overlay_key).set(
+ clamp_overlay_config_value(overlay_key, value),
+ )
+
# update location settings
if "location_source" in data:
self.config.location_source.set(data["location_source"])
@@ -17740,6 +18271,7 @@ class ReticulumMeshChat:
"rules": [
{
"id": rule["id"],
+ "name": rule.get("name") or "",
"identity_hash": rule["identity_hash"],
"forward_to_hash": rule["forward_to_hash"],
"source_filter_hash": rule["source_filter_hash"],
@@ -18717,6 +19249,16 @@ class ReticulumMeshChat:
"map_default_zoom": ctx.config.map_default_zoom.get(),
"map_tile_server_url": ctx.config.map_tile_server_url.get(),
"map_nominatim_api_url": ctx.config.map_nominatim_api_url.get(),
+ "map_overlay_max_bytes": ctx.config.map_overlay_max_bytes.get(),
+ "map_overlay_max_features": ctx.config.map_overlay_max_features.get(),
+ "map_overlay_max_kmz_uncompressed_bytes": ctx.config.map_overlay_max_kmz_uncompressed_bytes.get(),
+ "map_overlay_max_sources": ctx.config.map_overlay_max_sources.get(),
+ "map_overlay_max_concurrent_jobs": ctx.config.map_overlay_max_concurrent_jobs.get(),
+ "map_overlay_path_timeout_seconds": ctx.config.map_overlay_path_timeout_seconds.get(),
+ "map_overlay_transfer_timeout_seconds": ctx.config.map_overlay_transfer_timeout_seconds.get(),
+ "map_overlay_job_timeout_seconds": ctx.config.map_overlay_job_timeout_seconds.get(),
+ "map_overlay_max_retries": ctx.config.map_overlay_max_retries.get(),
+ "map_overlay_retry_delay_seconds": ctx.config.map_overlay_retry_delay_seconds.get(),
"do_not_disturb_enabled": ctx.config.do_not_disturb_enabled.get(),
"telephone_enabled": ctx.config.telephone_enabled.get(),
"telephone_allow_calls_from_contacts_only": ctx.config.telephone_allow_calls_from_contacts_only.get(),
@@ -20567,17 +21109,41 @@ class ReticulumMeshChat:
)
# convert destination hash to bytes
- destination_hash_bytes = bytes.fromhex(destination_hash)
+ try:
+ destination_hash_bytes = bytes.fromhex(destination_hash)
+ except (TypeError, ValueError) as exc:
+ msg = "Invalid destination hash."
+ raise ValueError(msg) from exc
+
+ wants_propagated = delivery_method == "propagated"
- # Reticulum keeps a live path table; entries expire when peers move or links drop.
- # We cannot replay "old" paths from the app layer — Transport.request_path refreshes discovery.
- path_outcome = await self._await_transport_path(destination_hash_bytes)
+ # Direct/opportunistic need a live peer path. Propagated uses the
+ # propagation node, so skip the peer path wait (still record outcome).
+ if wants_propagated:
+ path_outcome = reticulum_pathfinding.OutboundPathOutcome(
+ False, "skipped_for_propagated", False
+ )
+ else:
+ # Reticulum keeps a live path table; entries expire when peers move or links drop.
+ # We cannot replay "old" paths from the app layer — Transport.request_path refreshes discovery.
+ path_outcome = await self._await_transport_path(destination_hash_bytes)
destination_identity = self.recall_identity(destination_hash)
if destination_identity is None:
- # we have to bail out of sending, since we don't have the identity/path yet
- msg = "Could not find path to destination. Try again later."
- raise Exception(msg)
+ msg = (
+ "Could not recall destination identity. "
+ "Wait for an announce from this peer, then try again."
+ )
+ raise LookupError(msg)
+
+ # Direct/opportunistic delivery needs a live transport path. Propagated
+ # delivery can proceed without a peer path (it uses the propagation node).
+ if not wants_propagated and not path_outcome.path_available:
+ msg = (
+ "No path to destination. "
+ "Use Path Finder or wait for a route, then try again."
+ )
+ raise TimeoutError(msg)
# create destination for recipients lxmf delivery address
lxmf_destination = RNS.Destination(
@@ -21244,7 +21810,7 @@ class ReticulumMeshChat:
continue
# send new message with failed message content
- await self.send_message(
+ new_message = await self.send_message(
failed_message["destination_hash"],
failed_message["content"],
image_field=image_field,
@@ -21253,7 +21819,10 @@ class ReticulumMeshChat:
context=ctx,
)
- # remove original failed message from database
+ # Only drop the old failed row after a replacement was queued.
+ if new_message is None or getattr(new_message, "hash", None) is None:
+ continue
+
ctx.database.messages.delete_lxmf_message_by_hash(
failed_message["hash"],
)
@@ -21559,6 +22128,7 @@ def main():
# Initialize crash recovery system early to catch startup errors
recovery = CrashRecovery()
recovery.install()
+ install_rns_panic_containment()
parser = argparse.ArgumentParser(description="ReticulumMeshChat")
parser.add_argument(
diff --git a/meshchatx/src/backend/bug_report_manager.py b/meshchatx/src/backend/bug_report_manager.py
new file mode 100644
index 00000000..5e5aab11
--- /dev/null
+++ b/meshchatx/src/backend/bug_report_manager.py
@@ -0,0 +1,568 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Bug report collector and sender over aspect ``mcx-bugs-v1``."""
+
+from __future__ import annotations
+
+import json
+import os
+import threading
+import time
+from typing import Any
+
+import RNS
+
+from meshchatx.src.backend.announce_handler import AnnounceHandler
+
+BUG_ASPECT = "mcx-bugs-v1"
+REPORT_PATH = "/report"
+MAX_LOG_LINES = 500
+MAX_PAYLOAD_CHARS = 120_000
+MAX_STORED_REPORTS = 50
+MAX_COLLECTORS = 80
+
+
+class BugReportManager:
+ """Host-side collector destination and redacted log sender for plugins."""
+
+ def __init__(self, app: Any):
+ self.app = app
+ self._lock = threading.RLock()
+ self._destination: RNS.Destination | None = None
+ self._announce_handler: AnnounceHandler | None = None
+ self._collectors: dict[str, dict[str, Any]] = {}
+ self._reports: list[dict[str, Any]] = []
+ self._active_links: list[Any] = []
+ self._storage_dir: str | None = None
+ self._collector_name: str = ""
+
+ def _identity(self):
+ ctx = getattr(self.app, "current_context", None)
+ if ctx is None:
+ return None
+ return getattr(ctx, "identity", None)
+
+ def _ensure_storage_dir(self) -> str:
+ if self._storage_dir and os.path.isdir(self._storage_dir):
+ return self._storage_dir
+ base = getattr(self.app, "storage_dir", None) or os.getcwd()
+ path = os.path.join(base, "bug_reports")
+ os.makedirs(path, exist_ok=True)
+ self._storage_dir = path
+ return path
+
+ def status(self) -> dict[str, Any]:
+ with self._lock:
+ dest = self._destination
+ return {
+ "aspect": BUG_ASPECT,
+ "collector_running": dest is not None,
+ "destination_hash": dest.hash.hex() if dest is not None else None,
+ "collector_name": self._collector_name,
+ "collectors": len(self._collectors),
+ "reports": len(self._reports),
+ }
+
+ def start_collector(self, *, announce: bool = True) -> dict[str, Any]:
+ identity = self._identity()
+ if identity is None:
+ raise RuntimeError("identity is not available")
+ with self._lock:
+ if self._destination is not None:
+ if announce:
+ self._announce_locked()
+ return self.status()
+ app_name, aspects = RNS.Destination.app_and_aspects_from_name(BUG_ASPECT)
+ destination = RNS.Destination(
+ identity,
+ RNS.Destination.IN,
+ RNS.Destination.SINGLE,
+ app_name,
+ *aspects,
+ )
+ destination.set_link_established_callback(self._on_link)
+ destination.register_request_handler(
+ REPORT_PATH,
+ response_generator=self._report_response,
+ allow=RNS.Destination.ALLOW_ALL,
+ )
+ self._destination = destination
+ self._register_announce_handler()
+ dest_hex = destination.hash.hex()
+ self._collectors[dest_hex] = {
+ "destination_hash": dest_hex,
+ "aspect": BUG_ASPECT,
+ "name": "local",
+ "heard_at": time.time(),
+ "identity_hash": identity.hash.hex()
+ if hasattr(identity, "hash")
+ else None,
+ }
+ if announce:
+ self._announce_locked()
+ return self.status()
+
+ def stop_collector(self) -> dict[str, Any]:
+ with self._lock:
+ for link in list(self._active_links):
+ try:
+ link.teardown()
+ except Exception:
+ pass
+ self._active_links.clear()
+ if self._destination is not None:
+ dest_hex = self._destination.hash.hex()
+ self._collectors.pop(dest_hex, None)
+ try:
+ self._destination.deregister_request_handler(REPORT_PATH)
+ except Exception:
+ pass
+ try:
+ RNS.Transport.deregister_destination(self._destination)
+ except Exception:
+ pass
+ self._destination = None
+ self._unregister_announce_handler()
+ return self.status()
+
+ def announce(self) -> dict[str, Any]:
+ with self._lock:
+ if self._destination is None:
+ raise RuntimeError("collector is not running")
+ self._announce_locked()
+ return self.status()
+
+ def set_collector_name(self, name: str) -> dict[str, Any]:
+ with self._lock:
+ self._collector_name = str(name)[:64]
+ return self.status()
+
+ def _announce_locked(self) -> None:
+ assert self._destination is not None
+ display = ""
+ try:
+ ctx = getattr(self.app, "current_context", None)
+ config = getattr(ctx, "config", None) if ctx else None
+ if config is not None:
+ display = str(config.display_name.get() or "")
+ except Exception:
+ display = ""
+ name = self._collector_name
+ if not name:
+ name = display
+ app_data = json.dumps(
+ {"v": 1, "app": "meshchatx", "name": name[:64]},
+ separators=(",", ":"),
+ ).encode("utf-8")
+ self._destination.announce(app_data=app_data)
+
+ def _register_announce_handler(self) -> None:
+ if self._announce_handler is not None:
+ return
+ handler = AnnounceHandler(BUG_ASPECT, self._on_collector_announce)
+ RNS.Transport.register_announce_handler(handler)
+ self._announce_handler = handler
+
+ def _unregister_announce_handler(self) -> None:
+ handler = self._announce_handler
+ if handler is None:
+ return
+ try:
+ RNS.Transport.deregister_announce_handler(handler)
+ except Exception:
+ try:
+ if handler in RNS.Transport.announce_handlers:
+ RNS.Transport.announce_handlers.remove(handler)
+ except Exception:
+ pass
+ self._announce_handler = None
+
+ def ensure_discovery(self) -> None:
+ with self._lock:
+ self._register_announce_handler()
+
+ def _on_collector_announce(
+ self,
+ aspect: str,
+ destination_hash,
+ announced_identity,
+ app_data,
+ announce_packet_hash,
+ ) -> None:
+ try:
+ dest_hex = (
+ destination_hash.hex()
+ if isinstance(destination_hash, (bytes, bytearray))
+ else str(destination_hash)
+ )
+ name = ""
+ if isinstance(app_data, (bytes, bytearray)) and app_data:
+ try:
+ parsed = json.loads(app_data.decode("utf-8", errors="replace"))
+ if isinstance(parsed, dict):
+ name = str(parsed.get("name") or "")[:64]
+ except Exception:
+ name = app_data.decode("utf-8", errors="replace")[:64]
+ with self._lock:
+ self._collectors[dest_hex] = {
+ "destination_hash": dest_hex,
+ "aspect": aspect,
+ "name": name,
+ "heard_at": time.time(),
+ "identity_hash": (
+ announced_identity.hash.hex()
+ if announced_identity is not None
+ and hasattr(announced_identity, "hash")
+ else None
+ ),
+ }
+ if len(self._collectors) > MAX_COLLECTORS:
+ oldest = sorted(
+ self._collectors.values(),
+ key=lambda item: item.get("heard_at") or 0,
+ )
+ for entry in oldest[: len(self._collectors) - MAX_COLLECTORS]:
+ self._collectors.pop(entry["destination_hash"], None)
+ except Exception as exc:
+ print(f"bug report announce handling failed: {exc}")
+
+ def list_collectors(self) -> dict[str, Any]:
+ self.ensure_discovery()
+ with self._lock:
+ items = sorted(
+ self._collectors.values(),
+ key=lambda item: item.get("heard_at") or 0,
+ reverse=True,
+ )
+ return {"collectors": items, "aspect": BUG_ASPECT}
+
+ def list_reports(self, *, limit: int = 20) -> dict[str, Any]:
+ limit = max(1, min(int(limit or 20), MAX_STORED_REPORTS))
+ with self._lock:
+ return {"reports": list(self._reports[:limit])}
+
+ def delete_report(self, index: int) -> dict[str, Any]:
+ index = int(index)
+ with self._lock:
+ if index < 0 or index >= len(self._reports):
+ raise IndexError("report index out of range")
+ removed = self._reports.pop(index)
+ stamp = int(removed.get("received_at") or time.time())
+ directory = self._ensure_storage_dir()
+ path = os.path.join(directory, f"report-{stamp}-{os.getpid()}.json")
+ try:
+ os.remove(path)
+ except Exception:
+ pass
+ return {"ok": True}
+
+ def clear_reports(self) -> dict[str, Any]:
+ with self._lock:
+ self._reports.clear()
+ directory = self._ensure_storage_dir()
+ try:
+ for name in os.listdir(directory):
+ if name.startswith("report-") and name.endswith(".json"):
+ os.remove(os.path.join(directory, name))
+ except Exception:
+ pass
+ return {"ok": True}
+
+ def _on_link(self, link) -> None:
+ with self._lock:
+ self._active_links.append(link)
+ link.set_link_closed_callback(self._on_link_closed)
+
+ def _on_link_closed(self, link) -> None:
+ with self._lock:
+ if link in self._active_links:
+ self._active_links.remove(link)
+
+ def _report_response(
+ self,
+ path,
+ data,
+ request_id,
+ link_id,
+ remote_identity,
+ requested_at,
+ ):
+ try:
+ if isinstance(data, (bytes, bytearray)):
+ text = bytes(data).decode("utf-8", errors="replace")
+ elif isinstance(data, str):
+ text = data
+ else:
+ text = ""
+ payload = json.loads(text) if text else {}
+ if not isinstance(payload, dict):
+ return {"ok": False, "error": "invalid payload"}
+ source = None
+ if remote_identity is not None and hasattr(remote_identity, "hash"):
+ source = remote_identity.hash.hex()
+ report = {
+ "received_at": time.time(),
+ "source": source,
+ "title": str(payload.get("title") or "")[:200],
+ "description": str(payload.get("description") or "")[:4000],
+ "log_text": str(payload.get("log_text") or "")[:MAX_PAYLOAD_CHARS],
+ "meta": payload.get("meta")
+ if isinstance(payload.get("meta"), dict)
+ else {},
+ }
+ with self._lock:
+ self._reports.insert(0, report)
+ self._reports = self._reports[:MAX_STORED_REPORTS]
+ self._persist_report(report)
+ return {"ok": True}
+ except Exception as exc:
+ print(f"bug report receive failed: {exc}")
+ return {"ok": False, "error": str(exc)}
+
+ def _persist_report(self, report: dict[str, Any]) -> None:
+ try:
+ directory = self._ensure_storage_dir()
+ stamp = int(report.get("received_at") or time.time())
+ path = os.path.join(directory, f"report-{stamp}-{os.getpid()}.json")
+ with open(path, "w", encoding="utf-8") as handle:
+ json.dump(report, handle, indent=2)
+ except Exception as exc:
+ print(f"bug report persist failed: {exc}")
+
+ def read_debug_logs(
+ self,
+ *,
+ limit: int = 200,
+ search: str | None = None,
+ level: str | None = None,
+ module: str | None = None,
+ ) -> dict[str, Any]:
+ limit = max(1, min(int(limit or 200), MAX_LOG_LINES))
+ handler = None
+ try:
+ from meshchatx.src.backend import persistent_log_handler as plh
+
+ handler = getattr(plh, "memory_log_handler", None)
+ except Exception:
+ handler = None
+ if handler is None:
+ handler = getattr(self.app, "memory_log_handler", None)
+ if handler is None:
+ database = getattr(self.app, "database", None)
+ if database is not None and hasattr(database, "debug_logs"):
+ logs = database.debug_logs.get_logs(
+ limit=limit,
+ search=search,
+ level=level,
+ module=module,
+ )
+ total = database.debug_logs.get_total_count(
+ search=search,
+ level=level,
+ module=module,
+ )
+ return {"logs": logs, "total": total, "limit": limit}
+ return {"logs": [], "total": 0, "limit": limit}
+ logs = handler.get_logs(
+ limit=limit,
+ search=search,
+ level=level,
+ module=module,
+ )
+ total = handler.get_total_count(
+ search=search,
+ level=level,
+ module=module,
+ )
+ return {"logs": logs, "total": total, "limit": limit}
+
+ def preview_report(self, args: dict[str, Any]) -> dict[str, Any]:
+ limit = int(args.get("limit") or 200)
+ log_data = self.read_debug_logs(limit=limit)
+ lines = []
+ for entry in log_data.get("logs") or []:
+ ts = entry.get("timestamp")
+ level = entry.get("level") or ""
+ module = entry.get("module") or ""
+ message = entry.get("message") or ""
+ lines.append(f"{ts}\t{level}\t{module}\t{message}")
+ log_text = "\n".join(lines)
+ if len(log_text) > MAX_PAYLOAD_CHARS:
+ log_text = log_text[:MAX_PAYLOAD_CHARS] + "\n[truncated]"
+ return {
+ "log_text": log_text,
+ "chars": len(log_text),
+ "line_count": len(lines),
+ "total_available": log_data.get("total") or 0,
+ }
+
+ def _build_payload(self, args: dict[str, Any]) -> tuple[dict[str, Any], bytes]:
+ preview = self.preview_report(args)
+ title = str(args.get("title") or "MeshChatX bug report")[:200]
+ description = str(args.get("description") or "")[:4000]
+ payload = {
+ "v": 1,
+ "aspect": BUG_ASPECT,
+ "title": title,
+ "description": description,
+ "log_text": preview["log_text"],
+ "meta": {
+ "line_count": preview.get("line_count"),
+ "sent_at": time.time(),
+ },
+ }
+ body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
+ if len(body) > MAX_PAYLOAD_CHARS + 4096:
+ raise ValueError("report payload is too large")
+ return payload, body
+
+ def _send_remote_report(
+ self,
+ dest_hex: str,
+ dest_hash: bytes,
+ body: bytes,
+ args: dict[str, Any],
+ ) -> dict[str, Any]:
+ identity = RNS.Identity.recall(dest_hash)
+ if identity is None:
+ raise LookupError(
+ "Could not recall collector identity. "
+ "Wait for an mcx-bugs-v1 announce or start a local collector."
+ )
+ app_name, aspects = RNS.Destination.app_and_aspects_from_name(BUG_ASPECT)
+ destination = RNS.Destination(
+ identity,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ app_name,
+ *aspects,
+ )
+ if not RNS.Transport.has_path(dest_hash):
+ RNS.Transport.request_path(dest_hash)
+ deadline = time.time() + float(args.get("path_timeout") or 15)
+ while time.time() < deadline:
+ if RNS.Transport.has_path(dest_hash):
+ break
+ time.sleep(0.2)
+ if not RNS.Transport.has_path(dest_hash):
+ raise TimeoutError(
+ "No path to bug collector. "
+ "Start a collector locally to test, or wait for mesh announces."
+ )
+
+ link = RNS.Link(destination)
+ established = threading.Event()
+ response_event = threading.Event()
+ response_holder: dict[str, Any] = {"value": None, "error": None}
+
+ def on_established(lnk):
+ established.set()
+
+ def on_response(receipt):
+ response_holder["value"] = getattr(receipt, "response", None)
+ response_event.set()
+
+ def on_failed(receipt=None):
+ response_holder["error"] = "request failed"
+ response_event.set()
+
+ link.set_link_established_callback(on_established)
+ if not established.wait(timeout=float(args.get("link_timeout") or 20)):
+ try:
+ link.teardown()
+ except Exception:
+ pass
+ raise TimeoutError("Could not establish link to bug collector")
+
+ receipt = link.request(
+ REPORT_PATH,
+ data=body,
+ response_callback=on_response,
+ failed_callback=on_failed,
+ timeout=float(args.get("request_timeout") or 30),
+ )
+ if receipt is None:
+ try:
+ link.teardown()
+ except Exception:
+ pass
+ raise RuntimeError("Failed to send bug report request")
+ if not response_event.wait(timeout=float(args.get("request_timeout") or 30)):
+ try:
+ link.teardown()
+ except Exception:
+ pass
+ raise TimeoutError("Bug collector did not acknowledge the report")
+ try:
+ link.teardown()
+ except Exception:
+ pass
+ if response_holder.get("error"):
+ raise RuntimeError(response_holder["error"])
+ return {
+ "ok": True,
+ "destination_hash": dest_hex,
+ "bytes": len(body),
+ "line_count": json.loads(body).get("meta", {}).get("line_count"),
+ "response": response_holder.get("value"),
+ }
+
+ def _send_local_report(self, body: bytes) -> dict[str, Any]:
+ response = self._report_response(
+ path=REPORT_PATH,
+ data=body,
+ request_id=b"local",
+ link_id=None,
+ remote_identity=self._identity(),
+ requested_at=time.time(),
+ )
+ if not isinstance(response, dict) or not response.get("ok"):
+ error = (
+ response.get("error")
+ if isinstance(response, dict)
+ else "local delivery failed"
+ )
+ raise RuntimeError(f"Local collector rejected the report: {error}")
+ return {
+ "ok": True,
+ "destination_hash": "local",
+ "bytes": len(body),
+ "line_count": json.loads(body).get("meta", {}).get("line_count"),
+ "response": response,
+ }
+
+ def send_report(self, args: dict[str, Any]) -> dict[str, Any]:
+ dest_hex = args.get("destination_hash")
+ if not isinstance(dest_hex, str) or not dest_hex.strip():
+ raise ValueError("destination_hash is required")
+
+ if (
+ len(dest_hex) < 32
+ or len(dest_hex) > 64
+ or len(dest_hex) % 2 != 0
+ or any(ch not in "0123456789abcdefABCDEF" for ch in dest_hex)
+ ):
+ raise ValueError(
+ f"Invalid collector hash: '{dest_hex[:20]}{'...' if len(dest_hex) > 20 else ''}' "
+ f"must be 32–64 hex characters (even length)."
+ )
+ try:
+ dest_hash = bytes.fromhex(dest_hex)
+ except ValueError as exc:
+ raise ValueError(
+ f"Invalid collector hash: '{dest_hex[:20]}...' "
+ f"must be 32–64 hex characters (even length)."
+ ) from exc
+
+ _payload, body = self._build_payload(args)
+
+ with self._lock:
+ is_local = (
+ self._destination is not None
+ and self._destination.hash.hex().lower() == dest_hex.lower()
+ )
+
+ if is_local:
+ return self._send_local_report(body)
+
+ return self._send_remote_report(dest_hex, dest_hash, body, args)
diff --git a/meshchatx/src/backend/data/plugins/mcx-bugs/backend/main.py b/meshchatx/src/backend/data/plugins/mcx-bugs/backend/main.py
new file mode 100644
index 00000000..9f1cce64
--- /dev/null
+++ b/meshchatx/src/backend/data/plugins/mcx-bugs/backend/main.py
@@ -0,0 +1,40 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Thin Python backend for the bundled mcx-bugs plugin.
+
+Host manager capabilities do the real work. This module keeps activate/invoke
+hooks so the package exercises the Python plugin runtime.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+def activate(host) -> None:
+ host.storage_set("activated", "1")
+ host.log("mcx-bugs backend activated")
+
+
+def deactivate() -> None:
+ return None
+
+
+def invoke(method: str, args: dict[str, Any], host=None) -> Any:
+ if host is None:
+ host = args
+ args = method if isinstance(method, dict) else {}
+ method = "invoke"
+ args = args or {}
+ if method == "ping":
+ return {"ok": True, "activated": host.storage_get("activated")}
+ if method == "call":
+ capability = args.get("capability")
+ if not isinstance(capability, str) or not capability:
+ raise ValueError("capability is required")
+ return host.call_manager(capability, args.get("args") or {})
+ raise ValueError(f"unknown method: {method}")
+
+
+def on_hook(hook: str, payload: dict[str, Any], host) -> Any:
+ return None
diff --git a/meshchatx/src/backend/data/plugins/mcx-bugs/frontend/main.js b/meshchatx/src/backend/data/plugins/mcx-bugs/frontend/main.js
new file mode 100644
index 00000000..59732ddf
--- /dev/null
+++ b/meshchatx/src/backend/data/plugins/mcx-bugs/frontend/main.js
@@ -0,0 +1,716 @@
+/**
+ * @param {{ t: (key: string) => string }} api
+ * @param {string} key
+ * @param {Record<string, string | number>} [params]
+ */
+function formatLabel(api, key, params = {}) {
+ let text = api.t(key);
+ for (const [name, value] of Object.entries(params)) {
+ text = text.replace(`{${name}}`, String(value));
+ }
+ return text;
+}
+
+function shortHash(hash) {
+ if (!hash || hash.length < 12) {
+ return hash || "—";
+ }
+ return `${hash.slice(0, 10)}…${hash.slice(-6)}`;
+}
+
+function isHexHash(value) {
+ if (typeof value !== "string" || value.length === 0) {
+ return false;
+ }
+ if (value.length < 32 || value.length > 64 || value.length % 2 !== 0) {
+ return false;
+ }
+ return /^[0-9a-fA-F]+$/.test(value);
+}
+
+function toast(api, message, type, duration) {
+ if (typeof api.toast === "function") {
+ api.toast(message, type || "info", duration);
+ }
+}
+
+/**
+ * @param {{ t: (key: string) => string, invoke: Function, setUi: Function, onAction: Function, onRefresh: Function, onInput?: Function, getInputValue: Function, setInputValue?: Function, toast?: Function }} api
+ */
+export async function activate(api) {
+ let status = {
+ collector_running: false,
+ destination_hash: null,
+ collector_name: "",
+ collectors: 0,
+ reports: 0,
+ };
+ let collectors = [];
+ let reports = [];
+ let preview = null;
+ let activeTab = "send";
+ let selectedHash = "";
+ let viewingReport = null;
+ let lastMessage = "";
+ let lastMessageType = "info";
+
+ async function call(capability, args = {}) {
+ return api.invoke("call", { capability, args });
+ }
+
+ function setMessage(message, type = "info") {
+ lastMessage = message;
+ lastMessageType = type;
+ }
+
+ function render() {
+ const running = Boolean(status.collector_running);
+ const destHash = status.destination_hash ? String(status.destination_hash) : "";
+
+ const searchQuery = api.getInputValue("collector-search") || "";
+ const filteredCollectors = searchQuery
+ ? collectors.filter(
+ (c) =>
+ (c.destination_hash || "").toLowerCase().includes(searchQuery.toLowerCase()) ||
+ (c.name || "").toLowerCase().includes(searchQuery.toLowerCase())
+ )
+ : collectors;
+
+ api.setUi({
+ type: "column",
+ children: [
+ {
+ type: "text",
+ variant: "title",
+ value: formatLabel(api, "title"),
+ },
+ {
+ type: "text",
+ variant: "body",
+ value: formatLabel(api, "description"),
+ },
+ {
+ type: "actions",
+ items: [
+ {
+ type: "button",
+ id: "tab-send",
+ variant: activeTab === "send" ? undefined : "secondary",
+ label: formatLabel(api, "tab_send"),
+ },
+ {
+ type: "button",
+ id: "tab-collect",
+ variant: activeTab === "collect" ? undefined : "secondary",
+ label: formatLabel(api, "tab_collect"),
+ },
+ ],
+ },
+ lastMessage
+ ? {
+ type: "text",
+ variant:
+ lastMessageType === "error" ? "stat" : lastMessageType === "success" ? "body" : "caption",
+ value: lastMessage,
+ }
+ : null,
+ activeTab === "send"
+ ? {
+ type: "section",
+ title: formatLabel(api, "sender_section"),
+ children: [
+ {
+ type: "input",
+ id: "title",
+ label: formatLabel(api, "title_label"),
+ placeholder: formatLabel(api, "title_placeholder"),
+ value: api.getInputValue("title") || "",
+ },
+ {
+ type: "input",
+ id: "description",
+ label: formatLabel(api, "description_label"),
+ placeholder: formatLabel(api, "description_placeholder"),
+ value: api.getInputValue("description") || "",
+ multiline: true,
+ },
+ {
+ type: "input",
+ id: "collector-hash",
+ label: formatLabel(api, "collector_hash"),
+ placeholder: formatLabel(api, "collector_hash_placeholder"),
+ value: selectedHash,
+ },
+ selectedHash
+ ? {
+ type: "text",
+ variant: "caption",
+ value: formatLabel(api, "selected_collector", {
+ hash: shortHash(selectedHash),
+ }),
+ }
+ : null,
+ {
+ type: "actions",
+ items: [
+ {
+ type: "button",
+ id: "reset-destination",
+ variant: "secondary",
+ label: formatLabel(api, "reset_destination"),
+ },
+ ...(running && destHash
+ ? [
+ {
+ type: "button",
+ id: "use-local",
+ variant: "secondary",
+ label: formatLabel(api, "use_my_collector"),
+ },
+ ]
+ : []),
+ {
+ type: "button",
+ id: "preview",
+ variant: "secondary",
+ label: formatLabel(api, "preview"),
+ },
+ {
+ type: "button",
+ id: "send",
+ label: formatLabel(api, "send"),
+ },
+ ],
+ },
+ {
+ type: "input",
+ id: "collector-search",
+ label: formatLabel(api, "search_collectors"),
+ placeholder: formatLabel(api, "search_placeholder"),
+ value: api.getInputValue("collector-search") || "",
+ },
+ filteredCollectors.length
+ ? {
+ type: "list",
+ variant: "cards",
+ items: filteredCollectors.map((entry) => ({
+ type: "row",
+ variant: "card",
+ children: [
+ {
+ type: "text",
+ variant: "mono",
+ value: shortHash(entry.destination_hash),
+ },
+ {
+ type: "text",
+ variant: "caption",
+ value: entry.name || "—",
+ },
+ {
+ type: "button",
+ id: `use-${entry.destination_hash}`,
+ variant: "secondary",
+ label: formatLabel(api, "use_collector"),
+ },
+ ],
+ })),
+ }
+ : {
+ type: "text",
+ variant: "caption",
+ value: collectors.length
+ ? formatLabel(api, "no_search_results")
+ : formatLabel(api, "no_collectors"),
+ },
+ {
+ type: "actions",
+ items: [
+ {
+ type: "button",
+ id: "refresh",
+ variant: "secondary",
+ label: formatLabel(api, "refresh"),
+ },
+ ],
+ },
+ preview
+ ? {
+ type: "text",
+ variant: "mono",
+ value: preview.log_text ? String(preview.log_text).slice(0, 3000) : "",
+ }
+ : null,
+ ].filter(Boolean),
+ }
+ : activeTab === "collect"
+ ? {
+ type: "column",
+ children: [
+ {
+ type: "section",
+ title: formatLabel(api, "collector_section"),
+ description: running
+ ? formatLabel(api, "status_running", { hash: shortHash(destHash) })
+ : formatLabel(api, "status_idle"),
+ children: [
+ {
+ type: "input",
+ id: "collector-name",
+ label: formatLabel(api, "collector_name_label"),
+ placeholder: formatLabel(api, "collector_name_placeholder"),
+ value: api.getInputValue("collector-name") || status.collector_name || "",
+ },
+ {
+ type: "actions",
+ items: running
+ ? [
+ {
+ type: "button",
+ id: "save-name",
+ variant: "secondary",
+ label: formatLabel(api, "save_name"),
+ },
+ {
+ type: "button",
+ id: "announce",
+ variant: "secondary",
+ label: formatLabel(api, "announce"),
+ },
+ {
+ type: "button",
+ id: "stop-collector",
+ variant: "danger",
+ label: formatLabel(api, "stop_collector"),
+ },
+ ]
+ : [
+ {
+ type: "button",
+ id: "start-collector",
+ label: formatLabel(api, "start_collector"),
+ },
+ ],
+ },
+ running
+ ? {
+ type: "text",
+ variant: "mono",
+ value: destHash,
+ }
+ : null,
+ ].filter(Boolean),
+ },
+ {
+ type: "section",
+ title: formatLabel(api, "reports_section"),
+ children: [
+ reports.length
+ ? {
+ type: "list",
+ variant: "cards",
+ items: reports.map((entry, idx) => ({
+ type: "row",
+ variant: "card",
+ children: [
+ {
+ type: "column",
+ children: [
+ {
+ type: "text",
+ variant: "subtitle",
+ value: entry.title || "—",
+ },
+ {
+ type: "text",
+ variant: "caption",
+ value: formatLabel(api, "report_from", {
+ source: shortHash(entry.source),
+ }),
+ },
+ {
+ type: "text",
+ variant: "body",
+ value:
+ String(entry.description || "").slice(
+ 0,
+ 160
+ ) || "—",
+ },
+ ],
+ },
+ {
+ type: "actions",
+ items: [
+ {
+ type: "button",
+ id: `view-${idx}`,
+ variant: "secondary",
+ label: formatLabel(api, "view"),
+ },
+ {
+ type: "button",
+ id: `copy-${idx}`,
+ variant: "secondary",
+ label: formatLabel(api, "copy"),
+ },
+ {
+ type: "button",
+ id: `export-${idx}`,
+ variant: "secondary",
+ label: formatLabel(api, "export"),
+ },
+ {
+ type: "button",
+ id: `delete-${idx}`,
+ variant: "danger",
+ label: formatLabel(api, "delete"),
+ },
+ ],
+ },
+ ],
+ })),
+ }
+ : {
+ type: "text",
+ variant: "caption",
+ value: formatLabel(api, "no_reports"),
+ },
+ reports.length
+ ? {
+ type: "actions",
+ items: [
+ {
+ type: "button",
+ id: "clear-reports",
+ variant: "danger",
+ label: formatLabel(api, "clear_all"),
+ },
+ ],
+ }
+ : null,
+ ].filter(Boolean),
+ },
+ viewingReport
+ ? {
+ type: "section",
+ title: formatLabel(api, "view_report_section"),
+ children: [
+ {
+ type: "actions",
+ items: [
+ {
+ type: "button",
+ id: "close-view",
+ variant: "secondary",
+ label: formatLabel(api, "close"),
+ },
+ {
+ type: "button",
+ id: "copy-view",
+ variant: "secondary",
+ label: formatLabel(api, "copy"),
+ },
+ ],
+ },
+ {
+ type: "text",
+ variant: "mono",
+ value: JSON.stringify(viewingReport, null, 2).slice(0, 4000),
+ },
+ ],
+ }
+ : null,
+ ].filter(Boolean),
+ }
+ : null,
+ ].filter(Boolean),
+ });
+ }
+
+ function downloadJson(data, filename) {
+ const text = JSON.stringify(data, null, 2);
+ if (typeof api.download === "function") {
+ api.download(filename, text);
+ } else {
+ toast(api, "Download not supported in this environment", "error");
+ }
+ }
+
+ async function refresh() {
+ status = (await call("bugReport.status")) || status;
+ const listed = await call("bugReport.listCollectors");
+ collectors = listed?.collectors || [];
+ const received = await call("bugReport.listReports", { limit: 20 });
+ reports = received?.reports || [];
+ render();
+ }
+
+ api.onAction(async (actionId) => {
+ try {
+ if (actionId === "tab-send") {
+ activeTab = "send";
+ render();
+ return;
+ }
+ if (actionId === "tab-collect") {
+ activeTab = "collect";
+ await refresh();
+ render();
+ return;
+ }
+ if (typeof actionId === "string" && actionId.startsWith("use-") && actionId !== "use-local") {
+ const hash = actionId.slice(4);
+ if (isHexHash(hash)) {
+ selectedHash = hash.toLowerCase();
+ setMessage(
+ formatLabel(api, "destination_set", {
+ hash: shortHash(selectedHash),
+ }),
+ "success"
+ );
+ toast(
+ api,
+ formatLabel(api, "destination_set", {
+ hash: shortHash(selectedHash),
+ }),
+ "success"
+ );
+ } else {
+ setMessage(formatLabel(api, "invalid_hash", { value: hash }), "error");
+ toast(api, formatLabel(api, "invalid_hash", { value: hash }), "error");
+ }
+ render();
+ return;
+ }
+ if (actionId === "use-local") {
+ const fresh = await call("bugReport.status");
+ status = fresh || status;
+ const localHash = status.destination_hash ? String(status.destination_hash) : "";
+ if (isHexHash(localHash)) {
+ selectedHash = localHash.toLowerCase();
+ setMessage(formatLabel(api, "local_set") + ` (${shortHash(selectedHash)})`, "success");
+ toast(api, formatLabel(api, "local_set") + ` (${shortHash(selectedHash)})`, "success");
+ } else {
+ const msg = "No local collector running. Start one first.";
+ setMessage(msg, "warning");
+ toast(api, msg, "warning");
+ }
+ render();
+ return;
+ }
+ if (actionId === "reset-destination") {
+ selectedHash = "";
+ setMessage(formatLabel(api, "destination_reset"), "info");
+ toast(api, formatLabel(api, "destination_reset"), "info");
+ render();
+ return;
+ }
+ if (actionId === "refresh") {
+ await refresh();
+ return;
+ }
+ if (actionId === "preview") {
+ preview = await call("bugReport.preview", { limit: 200 });
+ setMessage(
+ formatLabel(api, "preview_stats", {
+ lines: preview?.line_count || 0,
+ chars: preview?.chars || 0,
+ }),
+ "info"
+ );
+ toast(
+ api,
+ formatLabel(api, "preview_stats", {
+ lines: preview?.line_count || 0,
+ chars: preview?.chars || 0,
+ }),
+ "info"
+ );
+ render();
+ return;
+ }
+ if (actionId === "send") {
+ if (!isHexHash(selectedHash)) {
+ const fresh = await call("bugReport.status");
+ status = fresh || status;
+ const localHash = status.destination_hash ? String(status.destination_hash) : "";
+ if (isHexHash(localHash)) {
+ selectedHash = localHash.toLowerCase();
+ }
+ }
+ if (!isHexHash(selectedHash)) {
+ const msg = formatLabel(api, "invalid_hash", {
+ value: selectedHash || "(empty)",
+ });
+ setMessage(msg, "error");
+ toast(api, msg, "error");
+ render();
+ return;
+ }
+ const result = await call("bugReport.send", {
+ destination_hash: selectedHash,
+ title: api.getInputValue("title") || "",
+ description: api.getInputValue("description") || "",
+ limit: 200,
+ });
+ const msg = formatLabel(api, "send_ok", {
+ bytes: result?.bytes || 0,
+ });
+ setMessage(msg, "success");
+ toast(api, msg, "success");
+ await refresh();
+ return;
+ }
+ if (actionId === "start-collector") {
+ const name = api.getInputValue("collector-name") || "";
+ if (name) {
+ await call("bugReport.setCollectorName", { name });
+ }
+ status = await call("bugReport.startCollector", {
+ announce: true,
+ });
+ const msg = formatLabel(api, "collector_started");
+ setMessage(msg, "success");
+ toast(api, msg, "success");
+ await refresh();
+ return;
+ }
+ if (actionId === "save-name") {
+ const name = api.getInputValue("collector-name") || "";
+ status = await call("bugReport.setCollectorName", { name });
+ await call("bugReport.announce");
+ const msg = formatLabel(api, "name_saved");
+ setMessage(msg, "success");
+ toast(api, msg, "success");
+ await refresh();
+ return;
+ }
+ if (actionId === "stop-collector") {
+ status = await call("bugReport.stopCollector");
+ const msg = formatLabel(api, "collector_stopped");
+ setMessage(msg, "info");
+ toast(api, msg, "info");
+ await refresh();
+ return;
+ }
+ if (actionId === "announce") {
+ status = await call("bugReport.announce");
+ const msg = formatLabel(api, "announce_ok");
+ setMessage(msg, "info");
+ toast(api, msg, "info");
+ render();
+ return;
+ }
+ if (typeof actionId === "string" && actionId.startsWith("view-")) {
+ const idx = parseInt(actionId.slice(5), 10);
+ viewingReport = reports[idx] || null;
+ if (viewingReport) {
+ activeTab = "collect";
+ }
+ render();
+ return;
+ }
+ if (actionId === "close-view") {
+ viewingReport = null;
+ render();
+ return;
+ }
+ if (actionId === "copy-view") {
+ if (viewingReport) {
+ const text = JSON.stringify(viewingReport, null, 2);
+ try {
+ await navigator.clipboard.writeText(text);
+ const msg = formatLabel(api, "report_copied");
+ setMessage(msg, "success");
+ toast(api, msg, "success");
+ } catch (err) {
+ const msg = formatLabel(api, "copy_failed", {
+ error: String(err),
+ });
+ setMessage(msg, "error");
+ toast(api, msg, "error");
+ }
+ }
+ render();
+ return;
+ }
+ if (typeof actionId === "string" && actionId.startsWith("delete-")) {
+ const idx = parseInt(actionId.slice(7), 10);
+ const entry = reports[idx];
+ await call("bugReport.deleteReport", { index: idx });
+ const msg = formatLabel(api, "report_deleted", {
+ title: entry?.title || "",
+ });
+ setMessage(msg, "info");
+ toast(api, msg, "info");
+ await refresh();
+ return;
+ }
+ if (typeof actionId === "string" && actionId.startsWith("copy-")) {
+ const idx = parseInt(actionId.slice(5), 10);
+ const entry = reports[idx];
+ const text = JSON.stringify(entry, null, 2);
+ try {
+ await navigator.clipboard.writeText(text);
+ const msg = formatLabel(api, "report_copied");
+ setMessage(msg, "success");
+ toast(api, msg, "success");
+ } catch (err) {
+ const msg = formatLabel(api, "copy_failed", {
+ error: String(err),
+ });
+ setMessage(msg, "error");
+ toast(api, msg, "error");
+ }
+ render();
+ return;
+ }
+ if (typeof actionId === "string" && actionId.startsWith("export-")) {
+ const idx = parseInt(actionId.slice(7), 10);
+ const entry = reports[idx];
+ const stamp = entry.received_at
+ ? new Date(typeof entry.received_at === "number" ? entry.received_at * 1000 : entry.received_at)
+ .toISOString()
+ .replace(/[:.]/g, "-")
+ : Date.now();
+ const filename = `bug-report-${stamp}.json`;
+ downloadJson(entry, filename);
+ const msg = formatLabel(api, "report_exported", {
+ filename,
+ });
+ setMessage(msg, "success");
+ toast(api, msg, "success");
+ render();
+ return;
+ }
+ if (actionId === "clear-reports") {
+ await call("bugReport.clearReports");
+ const msg = formatLabel(api, "reports_cleared");
+ setMessage(msg, "info");
+ toast(api, msg, "info");
+ await refresh();
+ return;
+ }
+ } catch (error) {
+ const message = error?.message || String(error);
+ setMessage(message, "error");
+ toast(api, message, "error");
+ render();
+ }
+ });
+
+ api.onRefresh(refresh);
+ if (typeof api.onInput === "function") {
+ api.onInput((id) => {
+ if (id === "collector-search") {
+ render();
+ }
+ if (id === "collector-hash") {
+ selectedHash = (api.getInputValue("collector-hash") || "").trim().toLowerCase();
+ render();
+ }
+ });
+ }
+ await refresh();
+}
diff --git a/meshchatx/src/backend/data/plugins/mcx-bugs/locales/en.json b/meshchatx/src/backend/data/plugins/mcx-bugs/locales/en.json
new file mode 100644
index 00000000..ddc87845
--- /dev/null
+++ b/meshchatx/src/backend/data/plugins/mcx-bugs/locales/en.json
@@ -0,0 +1,62 @@
+{
+ "nav": "Bug Reports",
+ "title": "Bug Reports",
+ "description": "Collect or send MeshChatX debug logs over the mesh using aspect mcx-bugs-v1.",
+ "tab_send": "Send",
+ "tab_collect": "Collect",
+ "sender_section": "Send Report",
+ "collector_section": "Run Collector",
+ "collectors_section": "Heard Collectors",
+ "reports_section": "Received Reports",
+ "preview_section": "Log Preview",
+ "title_label": "Title",
+ "title_placeholder": "Short summary of the problem",
+ "description_label": "Description",
+ "description_placeholder": "What happened, steps to reproduce, expected result",
+ "collector_hash": "Collector destination hash",
+ "collector_hash_placeholder": "Paste an mcx-bugs-v1 destination hash",
+ "selected_collector": "Selected: {hash}",
+ "reset_destination": "Reset",
+ "refresh": "Refresh",
+ "preview": "Preview logs",
+ "send": "Send report",
+ "start_collector": "Start collector",
+ "stop_collector": "Stop collector",
+ "announce": "Announce now",
+ "status_idle": "Collector is stopped.",
+ "status_running": "Collector running at {hash}",
+ "no_collectors": "No mcx-bugs-v1 collectors heard yet.",
+ "no_search_results": "No collectors match the search.",
+ "search_collectors": "Search collectors",
+ "search_placeholder": "Filter by hash or name",
+ "no_reports": "No reports received yet.",
+ "no_preview": "Preview logs to review what will be sent.",
+ "preview_stats": "{lines} lines, {chars} characters",
+ "send_ok": "Report sent ({bytes} bytes).",
+ "invalid_hash": "\"{value}\" is not a valid collector hash. Click 'Use my collector' to auto-fill, or paste a 32–64 character hex hash.",
+ "collector_started": "Collector started.",
+ "collector_stopped": "Collector stopped.",
+ "announce_ok": "Collector announced as mcx-bugs-v1.",
+ "use_collector": "Use",
+ "use_my_collector": "Use my collector",
+ "local_set": "Set to local collector.",
+ "report_from": "From {source}",
+ "destination_set": "Collector set to {hash}.",
+ "destination_reset": "Collector hash cleared.",
+ "collector_name_label": "Collector display name",
+ "collector_name_placeholder": "Name shown in announces (optional)",
+ "save_name": "Save name & announce",
+ "name_saved": "Name saved and announced.",
+ "copy": "Copy",
+ "export": "Export",
+ "delete": "Delete",
+ "view": "View",
+ "close": "Close",
+ "view_report_section": "Report Details",
+ "clear_all": "Clear all reports",
+ "report_deleted": "Report \"{title}\" deleted.",
+ "report_copied": "Report copied to clipboard.",
+ "copy_failed": "Copy failed: {error}",
+ "report_exported": "Exported as {filename}.",
+ "reports_cleared": "All reports cleared."
+}
diff --git a/meshchatx/src/backend/data/plugins/mcx-bugs/plugin.json b/meshchatx/src/backend/data/plugins/mcx-bugs/plugin.json
new file mode 100644
index 00000000..76a7a2c7
--- /dev/null
+++ b/meshchatx/src/backend/data/plugins/mcx-bugs/plugin.json
@@ -0,0 +1,57 @@
+{
+ "id": "com.meshchatx.mcx-bugs",
+ "version": "1.0.0",
+ "apiVersion": 1,
+ "name": "Bug Reports",
+ "description": "Send redacted debug logs to an mcx-bugs-v1 collector, or run a collector yourself.",
+ "frontend": {
+ "entry": "frontend/main.js",
+ "type": "js"
+ },
+ "backend": {
+ "entry": "backend/main.py",
+ "type": "python"
+ },
+ "i18n": {
+ "directory": "locales",
+ "defaultLocale": "en"
+ },
+ "contributes": {
+ "navItems": [
+ {
+ "id": "mcx-bugs",
+ "route": { "name": "plugin-mcx-bugs" },
+ "icon": "bug-outline",
+ "labelKey": "nav"
+ }
+ ],
+ "toolsPageEntries": [
+ {
+ "name": "mcx-bugs",
+ "route": { "name": "plugin-mcx-bugs" },
+ "icon": "bug-outline",
+ "iconBg": "tool-card__icon bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-200",
+ "titleKey": "title",
+ "descriptionKey": "description"
+ }
+ ]
+ },
+ "permissions": {
+ "managers": [
+ "debugLog.read",
+ "bugReport.status",
+ "bugReport.listCollectors",
+ "bugReport.listReports",
+ "bugReport.deleteReport",
+ "bugReport.clearReports",
+ "bugReport.preview",
+ "bugReport.send",
+ "bugReport.startCollector",
+ "bugReport.stopCollector",
+ "bugReport.announce",
+ "bugReport.setCollectorName"
+ ],
+ "storage": "isolated",
+ "network": "none"
+ }
+}
diff --git a/meshchatx/src/backend/plugin_manager.py b/meshchatx/src/backend/plugin_manager.py
index 539d2ab2..511370ae 100644
--- a/meshchatx/src/backend/plugin_manager.py
+++ b/meshchatx/src/backend/plugin_manager.py
@@ -873,6 +873,30 @@ class PluginManager:
raise PermissionError(f"capability not granted: {capability}")
if capability == "destinationPath.read":
return self._destination_path_read(args)
+ if capability == "debugLog.read":
+ return self._debug_log_read(args)
+ if capability == "bugReport.status":
+ return self._bug_report_call("status", args)
+ if capability == "bugReport.listCollectors":
+ return self._bug_report_call("list_collectors", args)
+ if capability == "bugReport.listReports":
+ return self._bug_report_call("list_reports", args)
+ if capability == "bugReport.deleteReport":
+ return self._bug_report_call("delete_report", args)
+ if capability == "bugReport.clearReports":
+ return self._bug_report_call("clear_reports", args)
+ if capability == "bugReport.preview":
+ return self._bug_report_call("preview_report", args)
+ if capability == "bugReport.send":
+ return self._bug_report_call("send_report", args)
+ if capability == "bugReport.startCollector":
+ return self._bug_report_call("start_collector", args)
+ if capability == "bugReport.stopCollector":
+ return self._bug_report_call("stop_collector", args)
+ if capability == "bugReport.announce":
+ return self._bug_report_call("announce", args)
+ if capability == "bugReport.setCollectorName":
+ return self._bug_report_call("set_collector_name", args)
if capability == "rnsLink.open":
return self._rns_link_open(args)
if capability == "rnsLink.identify":
@@ -885,6 +909,52 @@ class PluginManager:
return self._rns_link_close(args)
raise ValueError(f"unknown capability: {capability}")
+ def _require_bug_report_manager(self):
+ if not self.app:
+ raise RuntimeError("app is not available")
+ manager = getattr(self.app, "bug_report_manager", None)
+ if manager is None:
+ from meshchatx.src.backend.bug_report_manager import BugReportManager
+
+ manager = BugReportManager(self.app)
+ self.app.bug_report_manager = manager
+ return manager
+
+ def _debug_log_read(self, args: dict[str, Any]) -> dict[str, Any]:
+ manager = self._require_bug_report_manager()
+ return manager.read_debug_logs(
+ limit=int(args.get("limit") or 200),
+ search=args.get("search"),
+ level=args.get("level"),
+ module=args.get("module"),
+ )
+
+ def _bug_report_call(self, method: str, args: dict[str, Any]) -> Any:
+ manager = self._require_bug_report_manager()
+ if method == "status":
+ return manager.status()
+ if method == "list_collectors":
+ return manager.list_collectors()
+ if method == "list_reports":
+ return manager.list_reports(limit=int(args.get("limit") or 20))
+ if method == "delete_report":
+ return manager.delete_report(int(args.get("index") or 0))
+ if method == "clear_reports":
+ return manager.clear_reports()
+ if method == "preview_report":
+ return manager.preview_report(args or {})
+ if method == "send_report":
+ return manager.send_report(args or {})
+ if method == "start_collector":
+ return manager.start_collector(announce=bool(args.get("announce", True)))
+ if method == "stop_collector":
+ return manager.stop_collector()
+ if method == "announce":
+ return manager.announce()
+ if method == "set_collector_name":
+ return manager.set_collector_name(str(args.get("name") or ""))
+ raise ValueError(f"unknown bug report method: {method}")
+
def _require_rns_link_manager(self):
if not self.app:
raise RuntimeError("app is not available")
@@ -1433,6 +1503,12 @@ class PluginManager:
def install_bundled_examples(self) -> None:
if not self._plugins_runtime_enabled():
return
+ obsolete = "com.meshchatx.mesh-observatory"
+ if obsolete in self._plugins:
+ try:
+ self.remove(obsolete)
+ except Exception:
+ pass
bundled_root = os.path.join(os.path.dirname(__file__), "data", "plugins")
if not os.path.isdir(bundled_root):
return
@@ -1443,7 +1519,4 @@ class PluginManager:
manifest_path = os.path.join(source, "plugin.json")
if not os.path.isfile(manifest_path):
continue
- with open(manifest_path, encoding="utf-8") as handle:
- manifest = json.load(handle)
- if manifest.get("id") not in self._plugins:
- self.install_from_directory(source)
+ self.install_from_directory(source)
diff --git a/meshchatx/src/backend/plugin_permissions.py b/meshchatx/src/backend/plugin_permissions.py
index 7d0d327e..322bf1c8 100644
--- a/meshchatx/src/backend/plugin_permissions.py
+++ b/meshchatx/src/backend/plugin_permissions.py
@@ -19,6 +19,18 @@ KNOWN_HOOKS = frozenset(
KNOWN_MANAGERS = frozenset(
{
"destinationPath.read",
+ "debugLog.read",
+ "bugReport.status",
+ "bugReport.listCollectors",
+ "bugReport.listReports",
+ "bugReport.deleteReport",
+ "bugReport.clearReports",
+ "bugReport.preview",
+ "bugReport.send",
+ "bugReport.startCollector",
+ "bugReport.stopCollector",
+ "bugReport.announce",
+ "bugReport.setCollectorName",
"rnsLink.open",
"rnsLink.identify",
"rnsLink.request",
diff --git a/meshchatx/src/backend/rns_startup_recovery.py b/meshchatx/src/backend/rns_startup_recovery.py
new file mode 100644
index 00000000..54733149
--- /dev/null
+++ b/meshchatx/src/backend/rns_startup_recovery.py
@@ -0,0 +1,363 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Contain RNS process-killing exits and recover from bad interface configs.
+
+Reticulum's ``RNS.panic()`` calls ``os._exit(255)``, which kills the whole
+MeshChatX process (fatal on Android where Python runs in-process). Interface
+init failures can also leave the app unable to start until the user wipes
+storage. This module:
+
+1. Replaces ``RNS.panic`` / ``RNS.exit`` with catchable exceptions
+2. Forces ``panic_on_interface_error = No`` in the Reticulum config
+3. Progressively disables risky interfaces and retries RNS construction
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import re
+from typing import Any, Callable
+
+logger = logging.getLogger(__name__)
+
+_TRUE_STRINGS = ("true", "yes", "1", "on", "y")
+_PANIC_PATCHED = False
+_ORIGINAL_PANIC = None
+_ORIGINAL_EXIT = None
+
+# Prefer disabling these types first when init fails without a named culprit.
+_HIGH_RISK_TYPES = (
+ "I2PInterface",
+ "RNodeMultiInterface",
+ "RNodeInterface",
+ "RNodeIPInterface",
+ "AutoInterface",
+ "SerialInterface",
+ "KISSInterface",
+ "AX25KISSInterface",
+ "PipeInterface",
+)
+
+
+class RnsPanicError(RuntimeError):
+ """Raised instead of ``os._exit`` when RNS would panic or hard-exit."""
+
+
+def install_rns_panic_containment(*, force: bool = False) -> bool:
+ """Replace RNS panic/exit with exceptions so the HTTP process can survive.
+
+ Safe to call multiple times. Returns True when the patch was applied (or
+ was already applied).
+ """
+ global _PANIC_PATCHED, _ORIGINAL_PANIC, _ORIGINAL_EXIT
+ if _PANIC_PATCHED and not force:
+ return True
+ try:
+ import RNS
+ except Exception as exc:
+ logger.warning("Could not import RNS for panic containment: %s", exc)
+ return False
+
+ if _ORIGINAL_PANIC is None:
+ _ORIGINAL_PANIC = getattr(RNS, "panic", None)
+ if _ORIGINAL_EXIT is None:
+ _ORIGINAL_EXIT = getattr(RNS, "exit", None)
+
+ def _contained_panic(*_args, **_kwargs):
+ message = "RNS.panic() was called"
+ if _args:
+ message = f"RNS.panic(): {_args[0]}"
+ logger.error(message)
+ raise RnsPanicError(message)
+
+ def _contained_exit(code: int = 0):
+ message = f"RNS.exit({code}) was called"
+ logger.error(message)
+ try:
+ if hasattr(RNS, "Reticulum") and hasattr(RNS.Reticulum, "exit_handler"):
+ RNS.Reticulum.exit_handler()
+ except Exception as exc:
+ logger.warning("RNS exit_handler during contained exit failed: %s", exc)
+ if code != 0:
+ raise RnsPanicError(message)
+
+ RNS.panic = _contained_panic
+ RNS.exit = _contained_exit
+ _PANIC_PATCHED = True
+ logger.info("Installed RNS panic/exit containment (os._exit disabled for RNS)")
+ return True
+
+
+def ensure_panic_on_interface_error_disabled(config_path: str) -> bool:
+ """Force ``panic_on_interface_error = No`` so interface faults cannot kill RNS."""
+ if not os.path.isfile(config_path):
+ return False
+ try:
+ from RNS.vendor.configobj import ConfigObj
+
+ cfg = ConfigObj(config_path)
+ except Exception:
+ return False
+
+ reticulum = cfg.get("reticulum")
+ if not isinstance(reticulum, dict):
+ reticulum = {}
+ cfg["reticulum"] = reticulum
+
+ current = str(reticulum.get("panic_on_interface_error", "No")).strip().lower()
+ if current in ("no", "false", "0", "off", ""):
+ if "panic_on_interface_error" not in reticulum:
+ reticulum["panic_on_interface_error"] = "No"
+ try:
+ cfg.write()
+ except Exception:
+ return False
+ return True
+ return False
+
+ reticulum["panic_on_interface_error"] = "No"
+ try:
+ cfg.write()
+ except Exception as exc:
+ logger.warning(
+ "Failed to disable panic_on_interface_error in %s: %s",
+ config_path,
+ exc,
+ )
+ return False
+ logger.warning(
+ "Disabled panic_on_interface_error in %s so interface errors cannot "
+ "kill the MeshChatX process",
+ config_path,
+ )
+ return True
+
+
+def _is_enabled(iface: dict) -> bool:
+ for key in ("interface_enabled", "enabled"):
+ if key in iface and str(iface.get(key, "")).strip().lower() in _TRUE_STRINGS:
+ return True
+ return False
+
+
+def _disable_iface(iface: dict) -> None:
+ iface["interface_enabled"] = "false"
+ if "enabled" in iface:
+ iface["enabled"] = "false"
+
+
+def list_enabled_interface_names(config_path: str) -> list[str]:
+ if not os.path.isfile(config_path):
+ return []
+ try:
+ from RNS.vendor.configobj import ConfigObj
+
+ cfg = ConfigObj(config_path)
+ except Exception:
+ return []
+ interfaces = cfg.get("interfaces")
+ if not isinstance(interfaces, dict):
+ return []
+ return [
+ name
+ for name, iface in interfaces.items()
+ if isinstance(iface, dict) and _is_enabled(iface)
+ ]
+
+
+def disable_named_interfaces_in_config(
+ config_path: str,
+ names: list[str] | set[str],
+) -> list[str]:
+ """Disable the named interfaces. Returns names that were actually disabled."""
+ if not names or not os.path.isfile(config_path):
+ return []
+ try:
+ from RNS.vendor.configobj import ConfigObj
+
+ cfg = ConfigObj(config_path)
+ except Exception:
+ return []
+ interfaces = cfg.get("interfaces")
+ if not isinstance(interfaces, dict):
+ return []
+ disabled: list[str] = []
+ wanted = {str(n) for n in names}
+ for name, iface in interfaces.items():
+ if name not in wanted or not isinstance(iface, dict):
+ continue
+ if not _is_enabled(iface):
+ continue
+ _disable_iface(iface)
+ disabled.append(name)
+ logger.warning('Disabled interface "%s" during RNS startup recovery', name)
+ if not disabled:
+ return []
+ try:
+ cfg.write()
+ except Exception as exc:
+ logger.warning("Failed to write interface recovery config: %s", exc)
+ return []
+ return disabled
+
+
+def disable_interfaces_by_type(
+ config_path: str,
+ iface_types: tuple[str, ...] | list[str],
+ *,
+ limit: int | None = None,
+) -> list[str]:
+ if not os.path.isfile(config_path):
+ return []
+ try:
+ from RNS.vendor.configobj import ConfigObj
+
+ cfg = ConfigObj(config_path)
+ except Exception:
+ return []
+ interfaces = cfg.get("interfaces")
+ if not isinstance(interfaces, dict):
+ return []
+ type_set = {str(t) for t in iface_types}
+ disabled: list[str] = []
+ for name, iface in interfaces.items():
+ if not isinstance(iface, dict) or not _is_enabled(iface):
+ continue
+ if str(iface.get("type") or "").strip() not in type_set:
+ continue
+ _disable_iface(iface)
+ disabled.append(name)
+ logger.warning(
+ 'Disabled %s interface "%s" during RNS startup recovery',
+ iface.get("type"),
+ name,
+ )
+ if limit is not None and len(disabled) >= limit:
+ break
+ if not disabled:
+ return []
+ try:
+ cfg.write()
+ except Exception as exc:
+ logger.warning("Failed to write typed interface recovery config: %s", exc)
+ return []
+ return disabled
+
+
+def extract_interface_names_from_error(error: BaseException | str) -> list[str]:
+ """Best-effort parse of interface section names from an RNS error string."""
+ text = str(error)
+ found: list[str] = []
+ patterns = (
+ r'interface\s+"([^"]+)"',
+ r"interface\s+'([^']+)'",
+ r"Interface\[([^\]]+)\]",
+ r"I2PInterface\[([^\]]+)\]",
+ r"AutoInterface\[([^\]]+)\]",
+ r"TCPClientInterface\[([^\]]+)\]",
+ r"TCPServerInterface\[([^\]]+)\]",
+ r"RNodeInterface\[([^\]]+)\]",
+ r"The interface name \"([^\"]+)\" was already used",
+ )
+ for pattern in patterns:
+ for match in re.finditer(pattern, text, flags=re.IGNORECASE):
+ name = match.group(1).strip()
+ if name and name not in found:
+ found.append(name)
+ return found
+
+
+def apply_startup_recovery_step(
+ config_path: str,
+ error: BaseException | str,
+ *,
+ attempt: int,
+) -> list[str]:
+ """Disable something that might be blocking RNS init. Returns disabled names.
+
+ Steps escalate with *attempt*:
+ 0. Named interfaces from the error (if any), else I2P
+ 1. RNode / serial / kiss family
+ 2. AutoInterface
+ 3. Any remaining enabled high-risk interface (one at a time)
+ """
+ from meshchatx.src.backend import i2p_support
+ from meshchatx.src.backend.rnode_support import (
+ disable_rnode_interfaces_in_config,
+ _is_chaquopy_android,
+ )
+
+ disabled: list[str] = []
+ named = extract_interface_names_from_error(error)
+ if named:
+ disabled.extend(disable_named_interfaces_in_config(config_path, named))
+ if disabled:
+ return disabled
+
+ if attempt <= 0:
+ if i2p_support.disable_all_i2p_in_config(config_path):
+ # Names unknown here; report a synthetic marker for logs/tests.
+ disabled.append("__i2p__")
+ return disabled
+
+ if attempt == 1:
+ if disable_rnode_interfaces_in_config(
+ config_path,
+ is_android=_is_chaquopy_android(),
+ ):
+ disabled.append("__rnode__")
+ more = disable_interfaces_by_type(
+ config_path,
+ ("SerialInterface", "KISSInterface", "AX25KISSInterface", "PipeInterface"),
+ )
+ disabled.extend(more)
+ return disabled
+
+ if attempt == 2:
+ more = disable_interfaces_by_type(config_path, ("AutoInterface",))
+ disabled.extend(more)
+ return disabled
+
+ # Final attempts: peel off one high-risk enabled interface at a time.
+ for iface_type in _HIGH_RISK_TYPES:
+ more = disable_interfaces_by_type(config_path, (iface_type,), limit=1)
+ if more:
+ disabled.extend(more)
+ break
+ return disabled
+
+
+def create_reticulum_with_recovery(
+ config_dir: str,
+ *,
+ construct: Callable[[], Any],
+ max_attempts: int = 5,
+) -> Any:
+ """Construct RNS, progressively disabling bad interfaces on failure."""
+ install_rns_panic_containment()
+ config_path = os.path.join(config_dir, "config")
+ ensure_panic_on_interface_error_disabled(config_path)
+
+ last_exc: Exception | None = None
+ for attempt in range(max_attempts):
+ try:
+ return construct()
+ except Exception as exc:
+ last_exc = exc
+ disabled = apply_startup_recovery_step(
+ config_path,
+ exc,
+ attempt=attempt,
+ )
+ if not disabled:
+ break
+ print(
+ "Reticulum init failed; disabled "
+ f"{', '.join(disabled)} and retrying "
+ f"(attempt {attempt + 1}/{max_attempts}). "
+ f"Error: {exc}",
+ flush=True,
+ )
+ assert last_exc is not None
+ raise last_exc
diff --git a/meshchatx/src/frontend/components/Toast.vue b/meshchatx/src/frontend/components/Toast.vue
index 3c857d61..d2297f72 100644
--- a/meshchatx/src/frontend/components/Toast.vue
+++ b/meshchatx/src/frontend/components/Toast.vue
@@ -9,7 +9,7 @@
v-for="toast in toasts"
:key="toast.id"
ref="toastRefs"
- class="pointer-events-auto flex items-center p-4 w-full sm:min-w-[300px] sm:max-w-md rounded-xl shadow-lg border backdrop-blur-md transition-all duration-300 select-none touch-pan-y"
+ class="pointer-events-auto flex items-center p-4 w-full sm:min-w-[300px] sm:max-w-md rounded-xl shadow-lg border backdrop-blur-md transition-all duration-300 select-text touch-pan-y"
:class="[toastClass(toast.type), toast.swipeClass]"
:style="toastSwipeStyle(toast)"
@touchstart="onTouchStart($event, toast)"
diff --git a/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue b/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue
index 8f5ffee5..64b05e5f 100644
--- a/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue
+++ b/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue
@@ -9,7 +9,15 @@
<label v-if="node.label" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ node.label }}
</label>
+ <textarea
+ v-if="node.multiline"
+ class="w-full rounded-lg border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2.5 text-sm text-gray-900 dark:text-gray-100 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500 min-h-[6rem]"
+ :placeholder="node.placeholder || ''"
+ :value="node.value || ''"
+ @input="$emit('input', { id: node.id, value: $event.target.value })"
+ />
<input
+ v-else
class="w-full rounded-lg border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2.5 text-sm text-gray-900 dark:text-gray-100 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500"
type="text"
:placeholder="node.placeholder || ''"
@@ -18,6 +26,19 @@
/>
</div>
+ <label
+ v-else-if="node.type === 'checkbox'"
+ class="flex items-start gap-2.5 text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
+ >
+ <input
+ class="mt-0.5 rounded border-gray-300 dark:border-zinc-600 text-blue-600 focus:ring-blue-500/40"
+ type="checkbox"
+ :checked="Boolean(node.checked)"
+ @change="$emit('input', { id: node.id, value: $event.target.checked ? '1' : '0' })"
+ />
+ <span>{{ node.label }}</span>
+ </label>
+
<button v-else-if="node.type === 'button'" type="button" :class="buttonClass" @click="$emit('action', node.id)">
{{ node.label }}
</button>
@@ -169,7 +190,7 @@ export default {
methods: {
actionButtonClass(action) {
const base =
- "inline-flex items-center justify-center px-4 py-2 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/40";
+ "inline-flex items-center justify-center px-4 py-2 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/40 w-fit";
if (action.variant === "secondary") {
return `${base} border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-800`;
}
diff --git a/meshchatx/src/frontend/js/plugins/PluginHost.js b/meshchatx/src/frontend/js/plugins/PluginHost.js
index 8a241d87..25a1fe20 100644
--- a/meshchatx/src/frontend/js/plugins/PluginHost.js
+++ b/meshchatx/src/frontend/js/plugins/PluginHost.js
@@ -6,6 +6,7 @@ import { setPluginUiLabels, clearPluginUiLabels } from "./pluginUiRegistry.js";
import { registerNavItem, unregisterNavItem } from "../registries/navRegistry.js";
import { registerTool, unregisterTool } from "../registries/toolsRegistry.js";
import { onWsEvent, offWsEvent } from "../registries/wsEventRegistry.js";
+import ToastUtils from "../ToastUtils.js";
/** @typedef {import('./pluginManifest.js').PluginManifest} PluginManifest */
@@ -50,7 +51,7 @@ export class PluginHost {
}
const labels = await loadPluginLabelMap(apiClient, pluginId, locale, manifest);
setPluginUiLabels(pluginId, labels);
- const assetUrl = `/api/v1/plugins/${encodeURIComponent(pluginId)}/asset/${manifest.frontend.entry}`;
+ const assetUrl = `/api/v1/plugins/${encodeURIComponent(pluginId)}/asset/${manifest.frontend.entry}?v=${encodeURIComponent(manifest.version || "1")}`;
const sourceResponse = await apiClient.get(assetUrl, { responseType: "text" });
const source =
typeof sourceResponse.data === "string" ? sourceResponse.data : String(sourceResponse.data ?? "");
@@ -230,6 +231,26 @@ export class PluginHost {
);
this.unloadPlugin(pluginId);
}
+ if (message.type === "toast") {
+ ToastUtils.show(message.message || "", message.toastType || "info", message.duration ?? 5000);
+ }
+ if (message.type === "download") {
+ try {
+ const blob = new Blob([message.data || ""], {
+ type: "application/json",
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = message.filename || "download.json";
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ } catch (e) {
+ console.error("Plugin download failed:", e);
+ }
+ }
}
unloadPlugin(pluginId) {
diff --git a/meshchatx/src/frontend/js/plugins/pluginLabels.js b/meshchatx/src/frontend/js/plugins/pluginLabels.js
index d1cb356d..b788db6e 100644
--- a/meshchatx/src/frontend/js/plugins/pluginLabels.js
+++ b/meshchatx/src/frontend/js/plugins/pluginLabels.js
@@ -47,9 +47,13 @@ export async function loadPluginLabelMap(apiClient, pluginId, locale, manifest =
for (const code of candidates) {
try {
const assetPath = `${directory}/${code}.json`;
- const response = await apiClient.get(`/api/v1/plugins/${encodeURIComponent(pluginId)}/asset/${assetPath}`, {
- responseType: "json",
- });
+ const version = manifest.version || "1";
+ const response = await apiClient.get(
+ `/api/v1/plugins/${encodeURIComponent(pluginId)}/asset/${assetPath}?v=${encodeURIComponent(version)}`,
+ {
+ responseType: "json",
+ }
+ );
if (response.data && typeof response.data === "object") {
return flattenLocaleMessages(response.data);
}
diff --git a/meshchatx/src/frontend/js/plugins/pluginWorker.js b/meshchatx/src/frontend/js/plugins/pluginWorker.js
index 17f925e3..69337221 100644
--- a/meshchatx/src/frontend/js/plugins/pluginWorker.js
+++ b/meshchatx/src/frontend/js/plugins/pluginWorker.js
@@ -18,6 +18,7 @@ function handleWorkerMessage(event, post) {
ui: null,
inputValues: {},
actionHandler: null,
+ inputHandler: null,
eventHandlers: new Map(),
refreshHandler: null,
};
@@ -45,12 +46,24 @@ function handleWorkerMessage(event, post) {
onEvent(eventName, handler) {
state.eventHandlers.set(eventName, handler);
},
+ onInput(handler) {
+ state.inputHandler = handler;
+ },
getInputValue(id) {
return state.inputValues[id] ?? "";
},
+ setInputValue(id, value) {
+ state.inputValues[id] = value == null ? "" : String(value);
+ },
onRefresh(handler) {
state.refreshHandler = handler;
},
+ toast(message, type = "info", duration = 5000) {
+ post({ type: "toast", message, toastType: type, duration });
+ },
+ download(filename, data) {
+ post({ type: "download", filename, data });
+ },
};
function postRequest(kind, payload) {
@@ -102,6 +115,9 @@ function handleWorkerMessage(event, post) {
}
if (next.type === "input") {
state.inputValues[next.id] = next.value;
+ if (typeof state.inputHandler === "function") {
+ void state.inputHandler(next.id, next.value);
+ }
return;
}
if (next.type === "refresh-ui") {
diff --git a/tests/backend/test_bug_report_manager.py b/tests/backend/test_bug_report_manager.py
new file mode 100644
index 00000000..911ea05b
--- /dev/null
+++ b/tests/backend/test_bug_report_manager.py
@@ -0,0 +1,305 @@
+# SPDX-License-Identifier: 0BSD
+
+import json
+
+import pytest
+
+from meshchatx.src.backend.bug_report_manager import BugReportManager
+
+
+def _fake_app(tmp_path):
+ class FakeApp:
+ storage_dir = str(tmp_path)
+ current_context = None
+
+ return FakeApp()
+
+
+def test_preview_report_uses_database_logs(tmp_path):
+ class FakeLogs:
+ def get_logs(self, **_kwargs):
+ return [
+ {
+ "timestamp": 1.0,
+ "level": "ERROR",
+ "module": "meshchat",
+ "message": "fail at /tmp/x for aabbccddeeff00112233445566778899",
+ }
+ ]
+
+ def get_total_count(self, **_kwargs):
+ return 1
+
+ class FakeDatabase:
+ debug_logs = FakeLogs()
+
+ class FakeApp:
+ database = FakeDatabase()
+ storage_dir = str(tmp_path)
+ current_context = None
+
+ manager = BugReportManager(FakeApp())
+ preview = manager.preview_report({"limit": 5})
+ assert preview["line_count"] == 1
+ assert "/tmp/x" in preview["log_text"]
+ assert "aabbccddeeff00112233445566778899" in preview["log_text"]
+ assert preview["chars"] > 0
+
+
+def test_preview_report_empty_when_no_logs(tmp_path):
+ manager = BugReportManager(_fake_app(tmp_path))
+ preview = manager.preview_report({"limit": 10})
+ assert preview["line_count"] == 0
+ assert preview["chars"] == 0
+ assert preview["log_text"] == ""
+
+
+def test_list_collectors_and_reports_empty(tmp_path):
+ manager = BugReportManager(_fake_app(tmp_path))
+ assert manager.list_collectors()["collectors"] == []
+ assert manager.list_reports()["reports"] == []
+ assert manager.status()["collector_running"] is False
+
+
+def test_delete_and_clear_reports(tmp_path):
+ manager = BugReportManager(_fake_app(tmp_path))
+ manager._reports = [
+ {"received_at": 1000, "title": "A"},
+ {"received_at": 2000, "title": "B"},
+ {"received_at": 3000, "title": "C"},
+ ]
+ assert len(manager.list_reports(limit=10)["reports"]) == 3
+
+ result = manager.delete_report(1)
+ assert result["ok"] is True
+ assert len(manager.list_reports(limit=10)["reports"]) == 2
+ titles = [r["title"] for r in manager.list_reports(limit=10)["reports"]]
+ assert "B" not in titles
+
+ with pytest.raises(IndexError):
+ manager.delete_report(99)
+
+ result = manager.clear_reports()
+ assert result["ok"] is True
+ assert manager.list_reports()["reports"] == []
+
+
+def test_collector_name_is_truncated(tmp_path):
+ manager = BugReportManager(_fake_app(tmp_path))
+ assert manager.status()["collector_name"] == ""
+
+ manager.set_collector_name("Test Node")
+ assert manager.status()["collector_name"] == "Test Node"
+
+ manager.set_collector_name("x" * 100)
+ assert len(manager.status()["collector_name"]) <= 64
+
+
+def test_report_receive_and_list(tmp_path):
+ manager = BugReportManager(_fake_app(tmp_path))
+ response = manager._report_response(
+ path="/report",
+ data=json.dumps(
+ {"title": "test", "description": "desc", "log_text": "log"}
+ ).encode("utf-8"),
+ request_id=b"",
+ link_id=None,
+ remote_identity=None,
+ requested_at=0,
+ )
+ assert response["ok"] is True
+ reports = manager.list_reports(limit=10)["reports"]
+ assert len(reports) == 1
+ assert reports[0]["title"] == "test"
+ assert "redactions" not in reports[0]
+
+
+def test_send_report_rejects_invalid_hashes(tmp_path, monkeypatch):
+ manager = BugReportManager(_fake_app(tmp_path))
+ monkeypatch.setattr("RNS.Identity.recall", lambda _h: None)
+
+ with pytest.raises(ValueError, match="destination_hash is required"):
+ manager.send_report(
+ {
+ "destination_hash": "",
+ "title": "t",
+ "description": "d",
+ }
+ )
+
+ invalid_hashes = [
+ "local",
+ "abc",
+ "g" * 64,
+ "0" * 30,
+ "0" * 31,
+ "0" * 65,
+ "0" * 66,
+ "0" * 33,
+ ]
+ for h in invalid_hashes:
+ with pytest.raises(ValueError, match="Invalid collector hash"):
+ manager.send_report(
+ {
+ "destination_hash": h,
+ "title": "t",
+ "description": "d",
+ }
+ )
+
+
+def test_send_report_accepts_32_and_64_char_hashes(tmp_path, monkeypatch):
+ manager = BugReportManager(_fake_app(tmp_path))
+
+ received = []
+
+ def fake_report_response(*args, **kwargs):
+ received.append((args, kwargs))
+ return {"ok": True}
+
+ monkeypatch.setattr(manager, "_report_response", fake_report_response)
+
+ for length in (32, 64):
+ hash_value = "a" * length
+
+ class FakeDestination:
+ hash = type("H", (), {"hex": lambda _s, _hash=hash_value: _hash})()
+
+ manager._destination = FakeDestination()
+
+ result = manager.send_report(
+ {
+ "destination_hash": hash_value,
+ "title": f"len{length}",
+ "description": "d",
+ "limit": 5,
+ }
+ )
+ assert result["ok"] is True
+
+ assert len(received) == 2
+
+
+def test_send_local_report_bypasses_rns(tmp_path, monkeypatch):
+ manager = BugReportManager(_fake_app(tmp_path))
+ local_hash = "a" * 64
+
+ class FakeDestination:
+ hash = type("H", (), {"hex": lambda _s: local_hash})()
+
+ manager._destination = FakeDestination()
+
+ received = []
+
+ def fake_report_response(*args, **kwargs):
+ received.append((args, kwargs))
+ return {"ok": True}
+
+ monkeypatch.setattr(manager, "_report_response", fake_report_response)
+
+ result = manager.send_report(
+ {
+ "destination_hash": local_hash,
+ "title": "Local Bug",
+ "description": "desc",
+ "limit": 5,
+ }
+ )
+
+ assert result["ok"] is True
+ assert result["destination_hash"] == "local"
+ assert len(received) == 1
+ assert b"Local Bug" in received[0][1]["data"]
+
+
+def test_send_remote_report_requires_identity_recall(tmp_path, monkeypatch):
+ manager = BugReportManager(_fake_app(tmp_path))
+ remote_hash = "b" * 64
+
+ monkeypatch.setattr("RNS.Identity.recall", lambda _h: None)
+
+ with pytest.raises(LookupError, match="Could not recall collector identity"):
+ manager.send_report(
+ {
+ "destination_hash": remote_hash,
+ "title": "Remote Bug",
+ "description": "desc",
+ }
+ )
+
+
+def test_status_reflects_local_collector(tmp_path):
+ manager = BugReportManager(_fake_app(tmp_path))
+ local_hash = "c" * 64
+
+ class FakeDestination:
+ hash = type("H", (), {"hex": lambda _s: local_hash})()
+
+ manager._destination = FakeDestination()
+ manager._collector_name = "MyCollector"
+
+ status = manager.status()
+ assert status["collector_running"] is True
+ assert status["destination_hash"] == local_hash
+ assert status["collector_name"] == "MyCollector"
+ assert status["reports"] == 0
+ assert status["collectors"] == 0
+
+
+def test_build_payload_enforces_size_limit(tmp_path):
+ manager = BugReportManager(_fake_app(tmp_path))
+ manager._reports = []
+
+ class FakeLogs:
+ def get_logs(self, **_kwargs):
+ return [
+ {"timestamp": 1, "level": "INFO", "module": "m", "message": "x"}
+ ] * 10
+
+ def get_total_count(self, **_kwargs):
+ return 10
+
+ manager.app = type(
+ "A", (), {"database": type("DB", (), {"debug_logs": FakeLogs()})()}
+ )()
+
+ payload, body = manager._build_payload({"limit": 10})
+ assert payload["title"] == "MeshChatX bug report"
+ assert "log_text" in payload
+ assert len(body) > 0
+
+
+def test_clear_reports_deletes_persisted_files(tmp_path):
+ manager = BugReportManager(_fake_app(tmp_path))
+ manager._reports = [{"received_at": 1234, "title": "X"}]
+ manager._persist_report(manager._reports[0])
+
+ storage = manager._ensure_storage_dir()
+ assert any(f.endswith(".json") for f in manager._storage_dir_list(storage))
+
+ manager.clear_reports()
+ assert manager.list_reports()["reports"] == []
+ assert not any(f.endswith(".json") for f in manager._storage_dir_list(storage))
+
+
+# helper to avoid listing errors on empty dir
+
+
+def test_delete_report_deletes_persisted_file(tmp_path):
+ manager = BugReportManager(_fake_app(tmp_path))
+ manager._reports = [
+ {"received_at": 1000, "title": "A"},
+ {"received_at": 2000, "title": "B"},
+ ]
+ manager._persist_report(manager._reports[0])
+ manager._persist_report(manager._reports[1])
+
+ result = manager.delete_report(0)
+ assert result["ok"] is True
+ assert len(manager.list_reports(limit=10)["reports"]) == 1
+
+
+# Patch the manager to add a list helper for cross-platform robustness
+BugReportManager._storage_dir_list = lambda _self, path: [
+ name for name in __import__("os").listdir(path)
+]
diff --git a/tests/backend/test_plugin_manager.py b/tests/backend/test_plugin_manager.py
index e200f3c1..0fc63035 100644
--- a/tests/backend/test_plugin_manager.py
+++ b/tests/backend/test_plugin_manager.py
@@ -18,13 +18,13 @@ class TestPluginManagerInstall:
manager.install_bundled_examples()
plugins = manager.list_plugins()
ids = [plugin["id"] for plugin in plugins]
- assert "com.meshchatx.mesh-observatory" in ids
+ assert "com.meshchatx.mcx-bugs" in ids
assert "com.meshchatx.transport-node-monitor" not in ids
def test_enable_disable_plugin(self, tmp_path):
manager = _make_manager(tmp_path)
manager.install_bundled_examples()
- plugin_id = "com.meshchatx.mesh-observatory"
+ plugin_id = "com.meshchatx.mcx-bugs"
enabled = manager.enable(plugin_id)
assert enabled["enabled"] is True
disabled = manager.disable(plugin_id)
@@ -33,7 +33,7 @@ class TestPluginManagerInstall:
def test_storage_roundtrip(self, tmp_path):
manager = _make_manager(tmp_path)
manager.install_bundled_examples()
- plugin_id = "com.meshchatx.mesh-observatory"
+ plugin_id = "com.meshchatx.mcx-bugs"
manager.storage_set(plugin_id, "sample_key", json.dumps(["abc123"]))
value = manager.storage_get(plugin_id, "sample_key")
assert json.loads(value) == ["abc123"]
@@ -41,42 +41,46 @@ class TestPluginManagerInstall:
def test_permission_denied_for_manager_capability(self, tmp_path):
manager = _make_manager(tmp_path)
manager.install_bundled_examples()
- plugin_id = "com.meshchatx.mesh-observatory"
+ plugin_id = "com.meshchatx.mcx-bugs"
manager.enable(plugin_id)
with pytest.raises(PermissionError):
manager.call_manager(plugin_id, "unknown.capability", {})
- def test_destination_path_read_uses_rnpath_handler(self, tmp_path):
- class FakeHandler:
- def get_path_table(self, search=None, limit=0):
- return {
- "table": [
- {
- "hash": "abc123",
- "hops": 2,
- "via": "def456",
- "interface": "RNode LoRa",
- "state": 1,
- "timestamp": 1.0,
- }
- ],
- "total": 1,
- "responsive": 1,
- "unresponsive": 0,
- }
+ def test_bug_report_preview_reads_debug_logs(self, tmp_path):
+ class FakeLogs:
+ def get_logs(self, **_kwargs):
+ return [
+ {
+ "timestamp": 1.0,
+ "level": "INFO",
+ "module": "meshchat",
+ "message": "peer aa" + ("bb" * 15) + " at /home/user1/secret",
+ }
+ ]
+
+ def get_total_count(self, **_kwargs):
+ return 1
+
+ class FakeDatabase:
+ debug_logs = FakeLogs()
class FakeApp:
reticulum = object()
- rnpath_handler = FakeHandler()
+ database = FakeDatabase()
+ storage_dir = str(tmp_path)
+ current_context = None
manager = _make_manager(tmp_path, app=FakeApp())
manager.install_bundled_examples()
- plugin_id = "com.meshchatx.mesh-observatory"
+ plugin_id = "com.meshchatx.mcx-bugs"
manager.enable(plugin_id)
- result = manager.call_manager(plugin_id, "destinationPath.read", {"limit": 10})
- assert result["total"] == 1
- assert result["paths"][0]["destination_hash"] == "abc123"
- assert result["paths"][0]["interface"] == "RNode LoRa"
+ preview = manager.call_manager(
+ plugin_id,
+ "bugReport.preview",
+ {"limit": 10},
+ )
+ assert preview["line_count"] == 1
+ assert "/home/user1/secret" in preview["log_text"]
def test_rns_link_capabilities_require_manifest_grant(self, tmp_path):
class FakeLinkManager:
@@ -95,7 +99,7 @@ class TestPluginManagerInstall:
manager = _make_manager(tmp_path, app=FakeApp())
manager.install_bundled_examples()
- plugin_id = "com.meshchatx.mesh-observatory"
+ plugin_id = "com.meshchatx.mcx-bugs"
manager.enable(plugin_id)
with pytest.raises(PermissionError):
manager.call_manager(
@@ -139,7 +143,7 @@ class TestPluginManagerInstall:
manager = _make_manager(tmp_path, app=FakeApp())
manager.install_bundled_examples()
- plugin_id = "com.meshchatx.mesh-observatory"
+ plugin_id = "com.meshchatx.mcx-bugs"
manager.enable(plugin_id)
record = manager._plugins[plugin_id]
record.manifest.setdefault("permissions", {})["hooks"] = [
@@ -194,7 +198,7 @@ class TestPluginManagerInstall:
assert manager.list_plugins() == []
source = os.path.join(
os.path.dirname(__file__),
- "../../meshchatx/src/backend/data/plugins/mesh-observatory",
+ "../../meshchatx/src/backend/data/plugins/mcx-bugs",
)
with pytest.raises(PermissionError):
manager.install_from_directory(os.path.abspath(source))
diff --git a/tests/frontend/pluginLabels.test.js b/tests/frontend/pluginLabels.test.js
index 239db5b9..6304f0ee 100644
--- a/tests/frontend/pluginLabels.test.js
+++ b/tests/frontend/pluginLabels.test.js
@@ -10,10 +10,10 @@ import {
describe("pluginLabels", () => {
it("flattens nested plugin locale messages", () => {
const labels = flattenLocaleMessages({
- title: "Mesh Observatory",
+ title: "Bug Reports",
nested: { value: "Hello" },
});
- expect(labels.title).toBe("Mesh Observatory");
+ expect(labels.title).toBe("Bug Reports");
expect(labels["nested.value"]).toBe("Hello");
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────